package server import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "time" "chat-agent-system/internal/auth" "chat-agent-system/internal/sysadmin" "github.com/jackc/pgx/v5" ) type fakeSettingsStore struct { mu sync.Mutex items map[string]struct { value json.RawMessage revision int64 } audits []sysadmin.PolicyAudit nextID int64 } func newFakeSettingsStore() *fakeSettingsStore { return &fakeSettingsStore{items: map[string]struct { value json.RawMessage revision int64 }{}} } func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { f.mu.Lock() defer f.mu.Unlock() entry, ok := f.items[key] if !ok { return nil, nil, nil, 0, sysadmin.ErrSettingNotFound } at := time.Now() by := int64(1) return append(json.RawMessage(nil), entry.value...), &by, &at, entry.revision, nil } func (f *fakeSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) { return f.UpsertWithAudit(ctx, key, value, value, updatedBy, "") } func (f *fakeSettingsStore) UpsertWithAudit(_ context.Context, key string, value, auditAfter json.RawMessage, updatedBy int64, requestID string) (time.Time, int64, error) { f.mu.Lock() defer f.mu.Unlock() prev, exists := f.items[key] before := append(json.RawMessage(nil), prev.value...) after := append(json.RawMessage(nil), value...) rev := int64(0) if exists { rev = prev.revision + 1 } f.items[key] = struct { value json.RawMessage revision int64 }{value: after, revision: rev} f.nextID++ f.audits = append(f.audits, sysadmin.PolicyAudit{ ID: f.nextID, SettingKey: key, BeforeValue: before, AfterValue: append(json.RawMessage(nil), auditAfter...), ActorUserID: updatedBy, RequestID: requestID, CreatedAt: time.Now(), }) return time.Now(), rev, nil } func (f *fakeSettingsStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) { f.mu.Lock() defer f.mu.Unlock() entry, ok := f.items[key] if !ok { return time.Time{}, 0, sysadmin.ErrSettingNotFound } if entry.revision != expectedRevision { return time.Time{}, 0, sysadmin.ErrSettingRevisionConflict } newRev := entry.revision + 1 before := append(json.RawMessage(nil), entry.value...) after := append(json.RawMessage(nil), value...) f.items[key] = struct { value json.RawMessage revision int64 }{value: after, revision: newRev} f.nextID++ beforeRev := entry.revision f.audits = append(f.audits, sysadmin.PolicyAudit{ ID: f.nextID, SettingKey: key, BeforeValue: before, AfterValue: after, BeforeRevision: &beforeRev, AfterRevision: newRev, ActorUserID: updatedBy, RequestID: requestID, CreatedAt: time.Now(), }) return time.Now(), newRev, nil } func (f *fakeSettingsStore) GetForUpdate(ctx context.Context, _ pgx.Tx, key string) (json.RawMessage, *int64, *time.Time, int64, error) { return f.Get(ctx, key) } func (f *fakeSettingsStore) ListPolicyAudits(_ context.Context, key string, limit int) ([]sysadmin.PolicyAudit, error) { f.mu.Lock() defer f.mu.Unlock() if limit <= 0 { limit = 50 } out := make([]sysadmin.PolicyAudit, 0) for i := len(f.audits) - 1; i >= 0; i-- { a := f.audits[i] if a.SettingKey != key { continue } out = append(out, a) if len(out) >= limit { break } } return out, nil } // registerTestUser 注册并登录一个普通用户,返回其 Bearer Token。 func registerTestUser(t *testing.T, srv *Server, username string) string { t.Helper() if err := srv.AuthService().UpdateConfig(context.Background(), auth.SystemAuthConfig{ AllowRegistration: true, RequireApproval: false, }); err != nil { t.Fatalf("enable registration: %v", err) } body, _ := json.Marshal(map[string]string{ "username": username, "password": "PlainPass123", "name": "普通用户", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) w := httptest.NewRecorder() srv.Routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("register %s failed: %d %s", username, w.Code, w.Body.String()) } loginBody, _ := json.Marshal(map[string]string{"username": username, "password": "PlainPass123"}) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) w = httptest.NewRecorder() srv.Routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("login %s failed: %d %s", username, w.Code, w.Body.String()) } var session auth.SessionInfo if err := json.NewDecoder(w.Body).Decode(&session); err != nil || session.Token == "" { t.Fatalf("decode login session: %v", err) } return session.Token } func doSysadminRequest(t *testing.T, srv *Server, method, path, token, body string) *httptest.ResponseRecorder { t.Helper() var req *http.Request if body != "" { req = httptest.NewRequest(method, path, bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { withBearerAuth(req, token) } w := httptest.NewRecorder() srv.Routes().ServeHTTP(w, req) return w } func TestAdminSystemEndpoints_RequireSystemAdmin(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 plainToken := registerTestUser(t, srv, "plain_sysadmin_probe") endpoints := []struct { method string path string }{ {http.MethodGet, "/api/v1/admin/system/overview"}, {http.MethodGet, "/api/v1/admin/system/kb"}, {http.MethodGet, "/api/v1/admin/system/features"}, {http.MethodGet, "/api/v1/admin/system/settings"}, {http.MethodGet, "/api/v1/admin/system/settings/" + sysadmin.SettingKeyAllowAuthorOnlyApproval + "/audits"}, {http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice"}, } for _, ep := range endpoints { // 未认证 → 401 if w := doSysadminRequest(t, srv, ep.method, ep.path, "", ""); w.Code != http.StatusUnauthorized { t.Fatalf("%s %s: expected 401 without auth, got %d", ep.method, ep.path, w.Code) } // 非管理员 → 403 if w := doSysadminRequest(t, srv, ep.method, ep.path, plainToken, ""); w.Code != http.StatusForbidden { t.Fatalf("%s %s: expected 403 for non-admin, got %d %s", ep.method, ep.path, w.Code, w.Body.String()) } } } func TestAdminSystemOverview_AdminGetsCamelCasePayload(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 token := loginTestAdmin(t, srv) w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/overview", token, "") if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) } var body map[string]any if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } for _, key := range []string{"status", "env", "agentBackend", "databaseReady", "schemaVersion", "taskStoreReady", "kbOnline", "uptimeSeconds"} { if _, ok := body[key]; !ok { t.Fatalf("overview missing camelCase key %s: %v", key, body) } } if body["env"] != "test" || body["agentBackend"] != "stub" { t.Fatalf("unexpected overview payload: %v", body) } } func TestAdminSystemKB_NoSecretMaterial(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 token := loginTestAdmin(t, srv) w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/kb", token, "") if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) } var body map[string]any if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } for _, key := range []string{"online", "host", "port", "database", "user", "embeddingModel", "rerankerEnabled", "sslMode"} { if _, ok := body[key]; !ok { t.Fatalf("kb payload missing key %s: %v", key, body) } } if bytes.Contains(w.Body.Bytes(), []byte("assword")) { t.Fatalf("kb payload leaks secret material: %s", w.Body.String()) } } func TestAdminSystemFeatures_Payload(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 token := loginTestAdmin(t, srv) w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/features", token, "") if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) } var body struct { Features []sysadmin.Feature `json:"features"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode features: %v", err) } found := false for _, f := range body.Features { if f.Key == "FAV_PUBLISHER_ENABLED" && f.Group == "artifact-vault" && f.Source == "env" { found = true } } if !found { t.Fatal("features payload must include FAV_PUBLISHER_ENABLED in artifact-vault group") } } func TestAdminSystemSettings_ListAndUpdate(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 // 用带内存 store 的 sysadmin 服务替换装配产物(无 DB 时 store 为 nil)。 srv.setOverrideSysadmin(sysadmin.NewService(srv.cfg, sysadmin.Options{Store: newFakeSettingsStore()})) token := loginTestAdmin(t, srv) w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/settings", token, "") if w.Code != http.StatusOK { t.Fatalf("list: expected 200, got %d %s", w.Code, w.Body.String()) } var list struct { Settings []sysadmin.Setting `json:"settings"` } if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } if len(list.Settings) == 0 { t.Fatal("expected whitelist settings") } // 合法写 → 200,返回更新后的 setting w = doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice", token, `{"value":"停机维护"}`) if w.Code != http.StatusOK { t.Fatalf("update: expected 200, got %d %s", w.Code, w.Body.String()) } var updated sysadmin.Setting if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil { t.Fatalf("decode updated: %v", err) } if updated.Value != "停机维护" || !updated.Writable { t.Fatalf("unexpected updated setting: %+v", updated) } // 未知 key → 400 if w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/no.such.key", token, `{"value":"x"}`); w.Code != http.StatusBadRequest { t.Fatalf("unknown key: expected 400, got %d %s", w.Code, w.Body.String()) } // 类型不匹配 → 400 if w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.read_only_mode", token, `{"value":"yes"}`); w.Code != http.StatusBadRequest { t.Fatalf("type mismatch: expected 400, got %d %s", w.Code, w.Body.String()) } } func TestAdminSystemSettings_UpdateWithoutStoreReturns503(t *testing.T) { srv := newTestServer(t) // 无 DB:sysadmin store 为 nil token := loginTestAdmin(t, srv) w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice", token, `{"value":"x"}`) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503 without settings store, got %d %s", w.Code, w.Body.String()) } } func TestAdminSystemSettings_AuthorOnlyPolicyCAS(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false store := newFakeSettingsStore() // 模拟迁移种子行 _, _, _ = store.Upsert(context.Background(), sysadmin.SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`false`), 0) srv.setOverrideSysadmin(sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store})) token := loginTestAdmin(t, srv) plainToken := registerTestUser(t, srv, "plain_author_only_probe") path := "/api/v1/admin/system/settings/" + sysadmin.SettingKeyAllowAuthorOnlyApproval // 非管理员禁止 if w := doSysadminRequest(t, srv, http.MethodPut, path, plainToken, `{"value":true,"expected_revision":0}`); w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized { t.Fatalf("plain user: expected 401/403, got %d %s", w.Code, w.Body.String()) } // 缺少 expected_revision → 400 if w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":true}`); w.Code != http.StatusBadRequest { t.Fatalf("missing revision: expected 400, got %d %s", w.Code, w.Body.String()) } // null / 字符串布尔 → 400 for _, body := range []string{`{"value":null,"expected_revision":0}`, `{"value":"true","expected_revision":0}`} { if w := doSysadminRequest(t, srv, http.MethodPut, path, token, body); w.Code != http.StatusBadRequest { t.Fatalf("invalid bool %s: expected 400, got %d %s", body, w.Code, w.Body.String()) } } w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":true,"expected_revision":0}`) if w.Code != http.StatusOK { t.Fatalf("enable: expected 200, got %d %s", w.Code, w.Body.String()) } var enabled sysadmin.Setting if err := json.Unmarshal(w.Body.Bytes(), &enabled); err != nil { t.Fatalf("decode: %v", err) } if enabled.Value != true || enabled.Revision != 1 { t.Fatalf("expected true@1, got %+v", enabled) } // 过期 revision → 409 if w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":false,"expected_revision":0}`); w.Code != http.StatusConflict { t.Fatalf("stale revision: expected 409, got %d %s", w.Code, w.Body.String()) } } func TestAdminSystemSettings_ListAudits(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false store := newFakeSettingsStore() _, _, _ = store.Upsert(context.Background(), sysadmin.SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`false`), 0) srv.setOverrideSysadmin(sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store})) token := loginTestAdmin(t, srv) plainToken := registerTestUser(t, srv, "plain_audit_probe") path := "/api/v1/admin/system/settings/" + sysadmin.SettingKeyAllowAuthorOnlyApproval auditsPath := path + "/audits" if w := doSysadminRequest(t, srv, http.MethodGet, auditsPath, plainToken, ""); w.Code != http.StatusForbidden { t.Fatalf("plain user audits: expected 403, got %d %s", w.Code, w.Body.String()) } w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":true,"expected_revision":0}`) if w.Code != http.StatusOK { t.Fatalf("enable: expected 200, got %d %s", w.Code, w.Body.String()) } w = doSysadminRequest(t, srv, http.MethodGet, auditsPath+"?limit=5", token, "") if w.Code != http.StatusOK { t.Fatalf("audits: expected 200, got %d %s", w.Code, w.Body.String()) } var body struct { Audits []sysadmin.PolicyAudit `json:"audits"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } if len(body.Audits) < 1 { t.Fatalf("expected at least one audit, got %+v", body.Audits) } foundCAS := false for _, a := range body.Audits { if a.AfterRevision == 1 && string(a.AfterValue) == "true" { foundCAS = true break } } if !foundCAS { t.Fatalf("expected CAS audit @rev1 with true, got %+v", body.Audits) } if w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/settings/no.such.key/audits", token, ""); w.Code != http.StatusBadRequest { t.Fatalf("unknown key audits: expected 400, got %d %s", w.Code, w.Body.String()) } }