- 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 (baseline44161d2areproduced DATA RACE; now green under -race) - goose 00071 duplicate (dialogue merge1da17a28brought 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)
434 lines
15 KiB
Go
434 lines
15 KiB
Go
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())
|
||
}
|
||
}
|