Files
Agent-System-Backend/internal/server/auth_e2e_test.go
T
weichen b9c0a344b6 Implement owner-confirmed account recovery and account controls
Why: Administrator-issued password and login-key paths could take over target accounts, while old credentials and plugin handles ignored current account state.

Evidence: SELF receipts /tmp/iam-u3-backend-all-replay.json, /tmp/iam-u3-backend-final-pg-core.json, /tmp/iam-u3-backend-build.json, and focused HTTP/concurrency receipts in /tmp/iam-u3-*.json.

Impact: Adds trusted pending-account initialization and reissue, preexisting owner-session proof, proposal/rotation/grant transactions, server-side admin search, current Host guards, and legacy HTTP/CLI closure. Production cutover and downstream pins remain pending.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:20:48 +00:00

613 lines
23 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
// 认证与多租户 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() })
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, st
}
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 := st.CreateAPIKey("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, _ := st.CreateAPIKey("alice", "a")
keyB, _ := st.CreateAPIKey("bob", "b")
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)
}
// 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone {
t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String())
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK {
t.Fatalf("closed reset must preserve 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("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String())
}
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
key, err := st.CreateAPIKey("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 := st.CreateAPIKey("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 := st.CreateAPIKey("boss", "cli")
if err != nil {
t.Fatal(err)
}
keyDave, err := st.CreateAPIKey("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", ""))
}
}