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", `{"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] // 管理员建号(普通用户,初始密码)。 if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); 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","password":"alice-init-01"}`); rec.Code != http.StatusConflict { t.Fatalf("duplicate owner must 409: %d", rec.Code) } // 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":"ignored","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.StatusOK { t.Fatalf("admin reset: %d %s", rec.Code, rec.Body.String()) } if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { t.Fatalf("alice old session must be revoked after reset: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("reset must force change on next login: %s", 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":"reset-init-02"}`)) 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 key 时,有效会话不被一票否决; // /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:回落会话(200,authVia=session),不再被 key 一票否决。 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.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) { t.Fatalf("stale key + valid session must fall back to session: %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()) } }