package admin_test import ( "bytes" "context" "encoding/json" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "path/filepath" "strings" "testing" "git.asio.asia/nixevol/NixMsg/internal/admin" "git.asio.asia/nixevol/NixMsg/internal/app/group" "git.asio.asia/nixevol/NixMsg/internal/auth" "git.asio.asia/nixevol/NixMsg/internal/config" "git.asio.asia/nixevol/NixMsg/internal/store" ) func setupH02(t *testing.T) (warnBuf, auditBuf *bytes.Buffer, db *store.DB, srv *httptest.Server, client *http.Client) { t.Helper() prev := slog.Default() t.Cleanup(func() { slog.SetDefault(prev) }) warnBuf = &bytes.Buffer{} auditBuf = &bytes.Buffer{} slog.SetDefault(slog.New(slog.NewJSONHandler(warnBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))) auditLog := slog.New(slog.NewJSONHandler(auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() var err error db, err = store.Open(filepath.Join(dir, "data"), "FULL") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) hash := auth.NewStubHashPool() if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil { t.Fatal(seedErr) } gApp := group.New(group.Config{DB: db, MaxGroupMembers: 100}) h := admin.New(admin.Deps{ DB: db, Hash: hash, Tokens: admin.NewRandomAPITokens(), Locks: admin.NewMemoryLoginLocks(), Logger: slog.Default(), AuditLogger: auditLog, Groups: gApp, Config: config.Default(), }) srv = httptest.NewServer(h) t.Cleanup(srv.Close) jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } client = &http.Client{Jar: jar} return warnBuf, auditBuf, db, srv, client } func parseSlogJSON(t *testing.T, buf *bytes.Buffer) []map[string]any { t.Helper() var out []map[string]any for _, line := range bytes.Split(buf.Bytes(), []byte("\n")) { line = bytes.TrimSpace(line) if len(line) == 0 { continue } var m map[string]any if err := json.Unmarshal(line, &m); err != nil { t.Fatalf("slog json: %v line=%s", err, line) } out = append(out, m) } return out } func lastAuditByAction(t *testing.T, recs []map[string]any, action string) map[string]any { t.Helper() var last map[string]any for _, r := range recs { if r["msg"] == "admin_audit" && r["action"] == action { last = r } } if last == nil { t.Fatalf("missing admin_audit action=%s recs=%v", action, recs) } return last } func assertAuditCore(t *testing.T, rec map[string]any) { t.Helper() for _, k := range []string{"actor", "action", "result", "ip"} { v, _ := rec[k].(string) if v == "" { t.Fatalf("missing %s in %v", k, rec) } } } func assertNoSecrets(t *testing.T, raw string, secrets ...string) { t.Helper() for _, s := range secrets { if s != "" && strings.Contains(raw, s) { t.Fatalf("secret %q leaked in logs:\n%s", s, raw) } } } func TestH02AuditIndependentOfWarnLevel(t *testing.T) { warnBuf, auditBuf, _, srv, client := setupH02(t) login(t, client, srv.URL) recs := parseSlogJSON(t, auditBuf) loginRec := lastAuditByAction(t, recs, "login") assertAuditCore(t, loginRec) if loginRec["actor"] != "admin" { t.Fatalf("actor=%v", loginRec["actor"]) } if _, ok := loginRec["token_id"]; ok { t.Fatalf("cookie login should omit token_id: %v", loginRec) } warnRaw := warnBuf.String() if strings.Contains(warnRaw, `"msg":"admin_audit"`) || strings.Contains(warnRaw, `"msg":"admin_auth_fail"`) { t.Fatalf("warn logger should drop audit: %s", warnRaw) } if !strings.Contains(auditBuf.String(), `"msg":"admin_audit"`) { t.Fatalf("audit logger missing admin_audit: %s", auditBuf.String()) } } func TestH02AuthFailAndNoSecrets(t *testing.T) { _, auditBuf, _, srv, client := setupH02(t) wrong := "wrong-password-h02!!" res := postJSON(t, client, srv.URL+"/api/admin/login", `{"username":"admin","password":"`+wrong+`"}`, nil) env := decodeEnv(t, res) if res.StatusCode != http.StatusUnauthorized { t.Fatalf("want 401 got %d %+v", res.StatusCode, env) } res = doReq(t, client, http.MethodGet, srv.URL+"/api/admin/me", "", map[string]string{"Authorization": "Bearer nxm_deadbeefdeadbeefdeadbeefdeadbeef"}) env = decodeEnv(t, res) if res.StatusCode != http.StatusUnauthorized { t.Fatalf("bad token want 401 got %d %+v", res.StatusCode, env) } recs := parseSlogJSON(t, auditBuf) var badPass, badTok bool for _, r := range recs { if r["msg"] != "admin_auth_fail" { continue } if r["reason"] == "bad_password" { badPass = true } if r["reason"] == "invalid_token" { badTok = true } if r["ip"] == "" { t.Fatalf("admin_auth_fail missing ip: %v", r) } } if !badPass || !badTok { t.Fatalf("want bad_password and invalid_token, recs=%v", recs) } assertNoSecrets(t, auditBuf.String(), wrong, testPassword, "nxm_deadbeefdeadbeefdeadbeefdeadbeef") } func TestH02TokenFieldsDetailsAndNameLength(t *testing.T) { _, auditBuf, db, srv, client := setupH02(t) login(t, client, srv.URL) insertEndpoint(t, db, "ep-h02", "admin", true, false) res := postJSON(t, client, srv.URL+"/api/admin/tokens", `{"name":"`+strings.Repeat("名", 65)+`"}`, csrfHeaders()) env := decodeEnv(t, res) if res.StatusCode != http.StatusBadRequest { t.Fatalf("65-char name want 400 got %d %+v", res.StatusCode, env) } res = postJSON(t, client, srv.URL+"/api/admin/tokens", `{"name":"`+strings.Repeat("a", 64)+`"}`, csrfHeaders()) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("64-char name: %d %+v", res.StatusCode, env) } var created struct { ID string `json:"id"` Token string `json:"token"` } if err := json.Unmarshal(env.Data, &created); err != nil { t.Fatal(err) } tokClient := &http.Client{} hdr := map[string]string{"Authorization": "Bearer " + created.Token} res = doReq(t, tokClient, http.MethodPatch, srv.URL+"/api/admin/endpoints/ep-h02", `{"enabled":false}`, hdr) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("token patch: %d %+v", res.StatusCode, env) } res = doReq(t, client, http.MethodPatch, srv.URL+"/api/admin/tokens/"+created.ID, `{"enabled":false}`, map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"}) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("disable token: %d %+v", res.StatusCode, env) } regCode := "h02-reg-code-secret" res = doReq(t, client, http.MethodPut, srv.URL+"/api/admin/registration", `{"enabled":true,"code":"`+regCode+`"}`, map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"}) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("registration: %d %+v", res.StatusCode, env) } recs := parseSlogJSON(t, auditBuf) patch := lastAuditByAction(t, recs, "endpoint_patch") assertAuditCore(t, patch) if patch["token_id"] != created.ID { t.Fatalf("token_id=%v want %s", patch["token_id"], created.ID) } if !strings.HasPrefix(patch["actor"].(string), "token:") { t.Fatalf("actor=%v", patch["actor"]) } detail, _ := patch["detail"].(map[string]any) if detail == nil || detail["enabled"] != false { t.Fatalf("endpoint_patch detail=%v", patch["detail"]) } tokUpd := lastAuditByAction(t, recs, "token_update") td, _ := tokUpd["detail"].(map[string]any) if td == nil || td["enabled"] != false { t.Fatalf("token_update detail=%v", tokUpd["detail"]) } reg := lastAuditByAction(t, recs, "registration_update") rd, _ := reg["detail"].(map[string]any) if rd == nil || rd["enabled"] != true || rd["code_changed"] != true { t.Fatalf("registration detail=%v", reg["detail"]) } assertNoSecrets(t, auditBuf.String(), created.Token, regCode, testPassword) } func TestH02BatchPartialImportAndGroups(t *testing.T) { _, auditBuf, db, srv, client := setupH02(t) login(t, client, srv.URL) insertEndpoint(t, db, "keep-1", "admin", true, false) insertEndpoint(t, db, "alice", "admin", true, false) insertEndpoint(t, db, "bob", "admin", true, false) insertEndpoint(t, db, "carol", "admin", true, false) res := doReq(t, client, http.MethodPost, srv.URL+"/api/admin/endpoints/batch", `{"ids":["keep-1","missing-ep"],"action":"disable"}`, map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"}) env := decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("batch: %d %+v", res.StatusCode, env) } csvBody := "" + "id,name,login_password,talk_password,default_delay_seconds,remark\n" + "imp-1,甲,csv-pass-secret-1,,0,\n" + "imp-2,乙,csv-pass-secret-2,,0,\n" req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/admin/endpoints/import", strings.NewReader(csvBody)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "text/csv") req.Header.Set("X-Nixmsg-Request", "1") res, err = client.Do(req) if err != nil { t.Fatal(err) } env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("import: %d %+v", res.StatusCode, env) } res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups", `{"name":"一组","owner_id":"alice","member_ids":["bob"]}`, csrf()) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("group create: %d %+v", res.StatusCode, env) } var created struct { ID string `json:"id"` } if err := json.Unmarshal(env.Data, &created); err != nil { t.Fatal(err) } res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members", `{"member_ids":["carol","ghost-ep"]}`, csrf()) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("add members: %d %+v", res.StatusCode, env) } var addBody struct { Failed []any `json:"failed"` Added int `json:"added"` } if err := json.Unmarshal(env.Data, &addBody); err != nil { t.Fatal(err) } if addBody.Added != 1 { t.Fatalf("add response added=%d want 1", addBody.Added) } if len(addBody.Failed) != 1 { t.Fatalf("add response failed=%v want 1", addBody.Failed) } res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/transfer", `{"endpoint_id":"bob"}`, csrf()) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("transfer: %d %+v", res.StatusCode, env) } recs := parseSlogJSON(t, auditBuf) batch := lastAuditByAction(t, recs, "endpoint_batch_disable") assertAuditCore(t, batch) if batch["result"] != "partial" { t.Fatalf("batch result=%v rec=%v", batch["result"], batch) } bd, _ := batch["detail"].(map[string]any) if bd == nil { t.Fatalf("batch missing detail: %v", batch) } imp := lastAuditByAction(t, recs, "endpoint_import") id, _ := imp["detail"].(map[string]any) if id == nil { t.Fatalf("import missing detail: %v", imp) } if id["total"] != float64(2) { t.Fatalf("import total=%v", id["total"]) } ids, _ := id["ids"].([]any) if len(ids) != 2 { t.Fatalf("import ids=%v", id["ids"]) } add := lastAuditByAction(t, recs, "group_add_members") ad, _ := add["detail"].(map[string]any) if ad == nil { t.Fatalf("add members missing detail: %v", add) } if add["result"] != "partial" { t.Fatalf("add members result=%v want partial", add["result"]) } if _, ok := ad["members"]; !ok { t.Fatalf("add members missing members: %v", ad) } if _, ok := ad["failed"]; !ok { t.Fatalf("add members missing failed: %v", ad) } if ad["added"] != float64(1) { t.Fatalf("add members added=%v want 1", ad["added"]) } tr := lastAuditByAction(t, recs, "group_transfer") td, _ := tr["detail"].(map[string]any) if td == nil || td["new_owner"] != "bob" { t.Fatalf("transfer detail=%v", tr["detail"]) } assertNoSecrets(t, auditBuf.String(), "csv-pass-secret-1", "csv-pass-secret-2", testPassword) } func TestH02AddMembersAllSkippedAuditNotOK(t *testing.T) { _, auditBuf, db, srv, client := setupH02(t) login(t, client, srv.URL) insertEndpoint(t, db, "alice", "admin", true, false) insertEndpoint(t, db, "bob", "admin", true, false) res := doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups", `{"name":"一组","owner_id":"alice","member_ids":["bob"]}`, csrf()) env := decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("group create: %d %+v", res.StatusCode, env) } var created struct { ID string `json:"id"` } if err := json.Unmarshal(env.Data, &created); err != nil { t.Fatal(err) } auditBuf.Reset() var before int if err := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, created.ID).Scan(&before); err != nil { t.Fatal(err) } res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members", `{"member_ids":["bob","bob","alice"]}`, csrf()) env = decodeEnv(t, res) if res.StatusCode != http.StatusOK || !env.OK { t.Fatalf("add members: %d %+v", res.StatusCode, env) } var body struct { Failed []any `json:"failed"` Added int `json:"added"` } if err := json.Unmarshal(env.Data, &body); err != nil { t.Fatal(err) } if len(body.Failed) != 0 { t.Fatalf("failed=%v want empty (already members are not errors)", body.Failed) } if body.Added != 0 { t.Fatalf("added=%d want 0", body.Added) } var after int if err := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, created.ID).Scan(&after); err != nil { t.Fatal(err) } if after != before { t.Fatalf("member rows before=%d after=%d want unchanged", before, after) } recs := parseSlogJSON(t, auditBuf) add := lastAuditByAction(t, recs, "group_add_members") if add["result"] == "ok" { t.Fatalf("audit result must not be ok when all skipped: %v", add) } ad, _ := add["detail"].(map[string]any) if ad == nil { t.Fatalf("missing detail: %v", add) } if ad["added"] != float64(0) { t.Fatalf("detail.added=%v want 0", ad["added"]) } }