Why: the U4 branch is 89 commits behind main, which now carries the shared document renderer, two-trees project work, the SQLite instance guard and exec env layers (PR #209 merged asc76d8bd); it conflicted in storage migrations, host capabilities and the extension surface. The user authorized syncing U4 after the renderer line on 2026-10-18. Evidence: content is the agent-exec transport 3b64eb5 (cursor worker, unit backend-iam-u4-onto-main-c76d8bd-r1, run on a checkout whose tree equals main, receipt /tmp/cleanup-20261018/render/u4-backend-onto-run.json, SELF). Host check: main migrations 1-48 unchanged (migrate.go has no deleted lines versus main), U4 migrations moved from 35-36 to 49-50 with identical SQL, surface.txt is the sorted union, deps stay at main's pins. Impact: databases that ran unmerged U4 builds with the old numbering must be recreated. SurfaceVersion becomes 2.25.0-experimental. Main's admin password account creation now requires the system.users.create action like the other U4 account writes. Refs: Backend mainc76d8bd, PR #209 Co-authored-by: Cursor <cursoragent@cursor.com>
632 lines
24 KiB
Go
632 lines
24 KiB
Go
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)
|
||
}
|
||
|
||
// 管理员重置总是生成临时密码,销毁 alice 的旧会话并强制首登改密。
|
||
reset := session("POST", "/api/admin/users/alice/password", `{}`)
|
||
if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" {
|
||
t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String())
|
||
}
|
||
var resetBody struct {
|
||
TemporaryPassword string `json:"temporaryPassword"`
|
||
}
|
||
if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil || resetBody.TemporaryPassword == "" {
|
||
t.Fatalf("admin reset response: %+v %v", resetBody, err)
|
||
}
|
||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("reset must revoke 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.StatusUnauthorized {
|
||
t.Fatalf("old password remained usable after reset: %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.StatusOK || !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||
t.Fatalf("temporary password 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", ""))
|
||
}
|
||
}
|