ci / vet-and-test (pull_request) Failing after 1m25s
Why: U4 middleware restricts must_change sessions to me/password/logout; the legacy e2e expected admin create before first password change. Evidence: go test ./internal/store/ ./internal/server/ ./internal/app/ Impact: TestAdminAccountFlow matches set-password + reloginRequired. Refs: docs/svc-deploy/u4-one-time-login-key.md §5–6 Co-authored-by: Cursor <cursoragent@cursor.com>
530 lines
20 KiB
Go
530 lines
20 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() })
|
||
|
||
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())
|
||
}
|
||
}
|