Files
Agent-System-Backend/internal/server/auth_e2e_test.go
T
weichenandCursor 6533f0fa88 Add professional standards for every agent deliverable
Why: Generated files kept Word's default theme, US Letter pages,
hand-typed bullets and empty sections. Recipes only showed library
calls, the prompt had no craft standard, and seeded skills could not
be upgraded once written.

Evidence: go test ./... passed. The house-style suite covers three
themes across docx, pptx and xlsx, and rejects a document that repeats
the diagnosed layout failures. lint on the sample minutes exits 1.

Impact: Every run gets a resident quality standard. Office files use
house-style (switchable themes, templates, imitation) and must pass
lint. Skill seeding upgrades untouched builtins and keeps user edits.
Uploads land in inputs/, and the default style is a setting.

Refs: feature/agent-professional-deliverables
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 08:16:58 +00:00

432 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
// 认证与多租户 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;/api/me 携带身份与管理员位。
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.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;改密后清除。
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())
}
// alice 非管理员:建号被拒 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 改密(错误当前密码 → 401;成功 → must_change 清除)。
if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong current password: %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(), `"mustChangePassword":true`) {
t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String())
}
// 旧密码不能再登录;新密码可以且无需改密。
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)
}
// 管理员重置 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)
}
}