package server // 认证与多租户 e2e(阶段 1):API key 中间件、租户隔离(404 负例)、 // 会话在途互斥(409)、备份端点、SSE 越权拒绝。 import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "agent-system-backend/internal/bus" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" "agent-system-backend/internal/runmgr" "agent-system-backend/internal/runtime" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" core "agent-system-core" ) // newAuthTestServer:开启 API key 认证的组合根(测试版)。 func newAuthTestServer(t *testing.T) (*Server, *store.Store) { t.Helper() dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) return newAuthTestServerOnStore(t, st, dir), st } func newAuthTestServerOnStore(t *testing.T, st *store.Store, dir string) *Server { t.Helper() b := bus.New() reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) profSvc := profiles.New(st) profSvc.EnsureDefault() mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins")) srv := New(st, mgr, reg, profSvc, setSvc, ph) srv.Auth = NewAuthenticator(st, true) srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) } return srv } func TestAuthMiddleware(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() // 未带 key:401。 req := httptest.NewRequest("GET", "/api/projects", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("no key: %d", rec.Code) } // 创建密钥。 key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } // Bearer 头:200。 req = httptest.NewRequest("GET", "/api/projects", nil) req.Header.Set("Authorization", "Bearer "+key) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("bearer: %d", rec.Code) } // ?key= 查询参数(EventSource 路径):200。 req = httptest.NewRequest("GET", "/api/projects?key="+key, nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("query key: %d", rec.Code) } // 撤销后 401。 if ok, err := st.RevokeAPIKeyByName("alice", "e2e"); err != nil || !ok { t.Fatalf("revoke: %v %v", ok, err) } req = httptest.NewRequest("GET", "/api/projects", nil) req.Header.Set("Authorization", "Bearer "+key) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("revoked: %d", rec.Code) } } // 租户隔离(HTTP 负例):alice 的资源对 bob 一律 404。 func TestTenantIsolationHTTP(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() keyA, _ := createLegacyAPIKeyFixture(t, st, "alice", "a") keyB, _ := createLegacyAPIKeyFixture(t, st, "bob", "b") grantProjectCreateFixture(t, st, "alice") do := func(key, method, path string, body any) int { var buf bytes.Buffer if body != nil { json.NewEncoder(&buf).Encode(body) } req := httptest.NewRequest(method, path, &buf) req.Header.Set("Authorization", "Bearer "+key) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec.Code } if c := do(keyA, "POST", "/api/projects", map[string]any{"name": "A"}); c != 200 { t.Fatalf("alice create: %d", c) } var projects []map[string]any req := httptest.NewRequest("GET", "/api/projects", nil) req.Header.Set("Authorization", "Bearer "+keyA) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) json.Unmarshal(rec.Body.Bytes(), &projects) if len(projects) != 1 { t.Fatalf("alice projects = %v", projects) } projectID := projects[0]["id"].(string) // bob 看不到 alice 的项目/会话/工件。 if c := do(keyB, "PATCH", "/api/projects/"+projectID, map[string]any{"name": "劫持"}); c != 404 { t.Fatalf("bob patch alice project: %d", c) } if c := do(keyB, "GET", "/api/nonexistent", nil); c != 404 { t.Fatalf("unknown api path must 404, got %d", c) } if c := do(keyB, "GET", "/api/projects/"+projectID+"/conversations", nil); c != 404 { t.Fatalf("bob list alice conversations: %d", c) } if c := do(keyB, "DELETE", "/api/projects/"+projectID, nil); c != 404 { t.Fatalf("bob delete alice project: %d", c) } // bob 项目列表为空(不是 404,是空数组)。 req = httptest.NewRequest("GET", "/api/projects", nil) req.Header.Set("Authorization", "Bearer "+keyB) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var bobProjects []map[string]any json.Unmarshal(rec.Body.Bytes(), &bobProjects) if len(bobProjects) != 0 { t.Fatalf("bob projects = %v", bobProjects) } // bob 不能在 alice 项目下建会话。 if c := do(keyB, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "x"}); c != 404 { t.Fatalf("bob create conversation in alice project: %d", c) } // 设置按租户隔离。 if c := do(keyA, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); c != 200 { t.Fatalf("alice settings: %d", c) } req = httptest.NewRequest("GET", "/api/settings", nil) req.Header.Set("Authorization", "Bearer "+keyB) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var s map[string]any json.Unmarshal(rec.Body.Bytes(), &s) if u := s["user"].(map[string]any); len(u) != 0 { t.Fatalf("bob user settings must be empty, got %v", u) } } // 会话在途互斥:mock run 进行中(阻塞 runner)第二个消息 409。 func TestConversationBusyConflict(t *testing.T) { dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) b := bus.New() reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) profSvc := profiles.New(st) profSvc.EnsureDefault() block := make(chan struct{}, 1) // 缓冲:通知不阻塞执行 release := make(chan struct{}) // 阻塞 runner:第一个 run 挂住直到 release。 blocking := blockingRunner{block: block, release: release} mgr := runmgr.New(st, b, reg, profSvc, setSvc, blocking, blocking, &runtime.MockRunner{}, &runtime.MockSubagent{}) ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins")) srv := New(st, mgr, reg, profSvc, setSvc, ph) h := srv.Handler() // 建项目与会话(认证关闭,默认租户)。 _, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "p"}) projectID := out["id"].(string) _, out = doJSON(t, h, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "c"}) convID := out["id"].(string) msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}} conv, _ := st.GetConversation(store.OwnerDefault, convID) if _, err := mgr.StartConversationRun(conv, msg, nil, nil); err != nil { t.Fatal(err) } // 第二个 run:409。 if _, err := mgr.StartConversationRun(conv, msg, nil, nil); err != runmgr.ErrConversationBusy { t.Fatalf("second run must be busy, got %v", err) } // 释放第一个 run,槽位归还后可再启动。 close(release) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { _, err := mgr.StartConversationRun(conv, msg, nil, nil) if err == nil { return // 槽位已归还:通过 } if err != runmgr.ErrConversationBusy { t.Fatalf("unexpected: %v", err) } time.Sleep(10 * time.Millisecond) } t.Fatal("conversation slot not released after run finished") } type blockingRunner struct { block chan struct{} release chan struct{} } func (r blockingRunner) Name() string { return "blocking" } func (r blockingRunner) Execute(rc runtime.RunCtx) error { rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "done"}}) r.block <- struct{}{} // 通知已到位 <-r.release // 等待释放 return nil } // 备份端点:产出一致性副本文件。 func TestBackupEndpoint(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() code, out := doJSON(t, h, "POST", "/api/admin/backup", nil) if code != 200 { t.Fatalf("backup: %d %v", code, out) } path := out["path"].(string) if !strings.HasSuffix(path, ".db") { t.Fatalf("backup path: %s", path) } if _, err := os.Stat(path); err != nil { t.Fatalf("backup file: %v", err) } } // TestAdminAccountFlow 管理员发放制全流程(R2):CLI 语义的建号/首登改密/重置再改密/越权负例。 func TestAdminAccountFlow(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() // 引导:CLI 语义建管理员(随机初始密码,must_change=1)。 bootHash, err := store.HashPassword("boot-pass-00") if err != nil { t.Fatal(err) } if err := st.CreateAccount("admin", "管理员", bootHash, true); err != nil { t.Fatal(err) } // 登录前:静态页可访问、/api 需登录。 req := httptest.NewRequest("GET", "/", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusUnauthorized { t.Fatal("static login page must load without auth") } // 错误密码 401;正确密码建会话(Set-Cookie)。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"wrong"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("wrong password: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) } cookie := rec.Header().Get("Set-Cookie") if !strings.Contains(cookie, SessionCookie+"=") || !strings.Contains(cookie, "HttpOnly") { t.Fatalf("session cookie missing: %q", cookie) } tok := strings.Split(strings.Split(cookie, SessionCookie+"=")[1], ";")[0] if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("login must report mustChangePassword: %s", rec.Body.String()) } session := func(method, path, body string) *httptest.ResponseRecorder { var rdr io.Reader if body != "" { rdr = strings.NewReader(body) } req := httptest.NewRequest(method, path, rdr) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。 if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) { t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) } if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden { t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String()) } // 历史初始密码必须提供 current;成功后销毁会话,须重新密码登录。 if rec := session("POST", "/api/auth/password", `{"current":"boot-pass-00","next":"admin-final-9"}`); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String()) } if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { t.Fatalf("admin session must die after set-password: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String()) } tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] // 管理员只建无凭据的普通账号;历史强制改密夹具由 Store 直接设置。 if rec := session("POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"}}`); rec.Code != http.StatusOK { t.Fatalf("admin create user: %d %s", rec.Code, rec.Body.String()) } // 重名冲突 409。 if rec := session("POST", "/api/admin/users", `{"owner":"alice"}`); rec.Code != http.StatusConflict { t.Fatalf("duplicate owner must 409: %d", rec.Code) } legacyHash, err := store.HashPassword("alice-init-01") if err != nil { t.Fatal(err) } if err := st.SetUserPassword("alice", legacyHash); err != nil { t.Fatal(err) } // alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) aliceTok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] alice := func(method, path, body string) *httptest.ResponseRecorder { var rdr io.Reader if body != "" { rdr = strings.NewReader(body) } req := httptest.NewRequest(method, path, rdr) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: aliceTok}) if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("alice first login must force change: %s", rec.Body.String()) } if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { t.Fatalf("must_change non-admin create must 403: %d", rec.Code) } // must_change 改密要求 current;短密码拒绝;成功后 reloginRequired。 if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest { t.Fatalf("short password must 400: %d", rec.Code) } if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String()) } if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { t.Fatalf("alice session must die after set-password: %d", rec.Code) } // 旧密码不能再登录;新密码可以且无需改密。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("old password must fail after change: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String()) } aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] // 已设密普通用户:建号仍 403(非管理员)。 if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { t.Fatalf("non-admin create must 403: %d", rec.Code) } // 管理员签发一次性口令:不改正常密码、不销毁旧会话。 reset := session("POST", "/api/admin/users/alice/one-time-password", `{}`) if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { t.Fatalf("admin one-time password issue: %d %s", reset.Code, reset.Body.String()) } var resetBody struct { TemporaryPassword string `json:"oneTimePassword"` } if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil || resetBody.TemporaryPassword == "" { t.Fatalf("admin one-time password response: %+v %v", resetBody, err) } if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK { t.Fatalf("one-time issue must keep alice session: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("normal password login should still be full: %d %s", rec.Code, rec.Body.String()) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"`+resetBody.TemporaryPassword+`"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("unused one-time password should be invalidated by normal login: %d %s", rec.Code, rec.Body.String()) } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 key, err := createLegacyAPIKeyFixture(t, st, "admin", "cli") if err != nil { t.Fatal(err) } req = httptest.NewRequest("GET", "/api/me", nil) req.Header.Set("Authorization", "Bearer "+key) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("api key still works: %d", rec.Code) } // 登录失败退避:连续 5 次错误后 429。 for i := 0; i < 5; i++ { req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"x"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusTooManyRequests { t.Fatalf("backoff expected 429: %d", rec.Code) } // 登出:会话失效。 if rec := session("POST", "/api/auth/logout", ""); rec.Code != http.StatusOK { t.Fatalf("logout: %d", rec.Code) } if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { t.Fatalf("session must die after logout: %d", rec.Code) } } // TestStaleKeySessionFallback:混合 Bearer 与 cookie 的认证上下文不确定,必须拒绝。 // /api/me 的 authVia 仍区分单独有效的 key / session。 func TestStaleKeySessionFallback(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() hash, err := store.HashPassword("init-pass-01") if err != nil { t.Fatal(err) } if err := st.CreateAccount("carol", "卡罗", hash, false); err != nil { t.Fatal(err) } req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"carol","password":"init-pass-01"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) } tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] // 会话 + 失效 key:拒绝混合凭证,不把有效 cookie 当证明兜底。 req = httptest.NewRequest("GET", "/api/me", nil) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) req.Header.Set("Authorization", "Bearer ask_stale_key") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("stale key + valid session must be rejected: %d %s", rec.Code, rec.Body.String()) } // 失效 key 且无会话:仍 401(key 专属文案)。 req = httptest.NewRequest("GET", "/api/me", nil) req.Header.Set("Authorization", "Bearer ask_stale_key") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid or revoked API key") { t.Fatalf("stale key alone must 401 with key error: %d %s", rec.Code, rec.Body.String()) } // 有效 key:authVia=key。 key, err := createLegacyAPIKeyFixture(t, st, "carol", "cli") if err != nil { t.Fatal(err) } req = httptest.NewRequest("GET", "/api/me", nil) req.Header.Set("Authorization", "Bearer "+key) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"key"`) { t.Fatalf("me via key: %d %s", rec.Code, rec.Body.String()) } // 纯会话:authVia=session。 req = httptest.NewRequest("GET", "/api/me", nil) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) { t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) } } // TestSystemSettingsAdminGate:P2 系统设置面按管理员准入——普通账号 403, // 管理员可读写且值对所有租户生效;普通账号写受管项仍 400。 func TestSystemSettingsAdminGate(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() hash, err := store.HashPassword("init-pass-01") if err != nil { t.Fatal(err) } if err := st.CreateAccount("boss", "管理员甲", hash, true); err != nil { t.Fatal(err) } if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil { t.Fatal(err) } keyBoss, err := createLegacyAPIKeyFixture(t, st, "boss", "cli") if err != nil { t.Fatal(err) } keyDave, err := createLegacyAPIKeyFixture(t, st, "dave", "cli") if err != nil { t.Fatal(err) } // 初始密码改密门不参与本测试对象(受管拦截语义),先解除。 if err := st.ClearMustChangePassword("boss"); err != nil { t.Fatal(err) } if err := st.ClearMustChangePassword("dave"); err != nil { t.Fatal(err) } do := func(key, method, path string, body string) *httptest.ResponseRecorder { var rd io.Reader if body != "" { rd = strings.NewReader(body) } req := httptest.NewRequest(method, path, rd) req.Header.Set("Authorization", "Bearer "+key) if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // 普通账号:读写系统面均 403;写受管项走 user 面仍是 400(先于管理员判定也成立)。 if rec := do(keyDave, "GET", "/api/settings/system", ""); rec.Code != http.StatusForbidden { t.Fatalf("dave get system: %d", rec.Code) } if rec := do(keyDave, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusForbidden { t.Fatalf("dave put system: %d", rec.Code) } if rec := do(keyDave, "PUT", "/api/settings/user", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusBadRequest { t.Fatalf("dave managed via user: %d body=%s", rec.Code, rec.Body.String()) } if rec := do(keyDave, "PUT", "/api/settings/user", `{"ui":{"theme":"light"}}`); rec.Code != http.StatusOK { t.Fatalf("dave non-managed via user: %d body=%s", rec.Code, rec.Body.String()) } // 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。 if rec := do(keyBoss, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}}`); rec.Code != http.StatusOK { t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String()) } rec := do(keyBoss, "GET", "/api/settings/system", "") if rec.Code != http.StatusOK { t.Fatalf("boss get system: %d", rec.Code) } if strings.Contains(rec.Body.String(), "sk-boss-secret") { t.Fatalf("system read leaked secret key") } if eff := settings.NewService(st).Effective("dave", ""); eff.GetString("models.chat.model", "") != "m-boss" { t.Fatalf("system value not effective for dave: %q", eff.GetString("models.chat.model", "")) } }