Files
Agent-System/internal/server/admin_system_handlers_test.go
T
weichen 3e01f18756 merge: integrate final-artifact-vault into local_dev (authorized exception)
User authorized lifting CURRENT FAV whole-branch merge protection on
2026-09-23. Keep FAV_DEGRADED_READ/FAV_PUBLISHER_ENABLED default false.
Drop duplicate goose 00034 (local_dev already has 00036). Preserve
agentx workspace admin routes and pin-export wiring.
2026-09-23 13:57:08 +00:00

425 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(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
prev := f.items[key]
before := append(json.RawMessage(nil), prev.value...)
after := append(json.RawMessage(nil), value...)
f.items[key] = struct {
value json.RawMessage
revision int64
}{value: after, revision: prev.revision}
f.nextID++
f.audits = append(f.audits, sysadmin.PolicyAudit{
ID: f.nextID,
SettingKey: key,
BeforeValue: before,
AfterValue: after,
ActorUserID: updatedBy,
CreatedAt: time.Now(),
})
return time.Now(), prev.revision, 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.sysadmin = 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.sysadmin = 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.sysadmin = 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())
}
}