Files
Agent-System/internal/server/admin_system_handlers_test.go
T
weichen a9c9a9182e refactor(server): remove 24 mirrored Runtime fields via nil-safe accessors; fix Shutdown-vs-background-loop DATA RACE and goose 00071 duplicate version
- Server no longer mirrors app.Runtime services (gov/svc/pwSvc/...): 574 call sites now use accessors in runtime_accessors.go
- backgroundLoop (mutex+sync.Once) stops sweeper/publisher/reminder/wsGC goroutines race-free (baseline 44161d2a reproduced DATA RACE; now green under -race)
- goose 00071 duplicate (dialogue merge 1da17a28 brought two 00071): context-conflict-adjudications renumbered to 00072; dev DB applied only through 00070 so zero data migration
- task_finalize tests build minimal app.Runtime; test injection via serverTestOverrides + setOverride* (explicit-nil markers for fail-closed cases)
2026-09-25 13:17:22 +00:00

434 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}