package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/kairo/haven/internal/audit" "github.com/kairo/haven/internal/auth" "github.com/kairo/haven/internal/config" "github.com/kairo/haven/internal/db" ) // 端到端测试:真实 PostgreSQL + 完整路由栈,逐条对照 docs/api.md。 // // HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' \ // go test ./cmd/haven -v type testEnv struct { t *testing.T router *gin.Engine store *auth.SQLStore svc *auth.Service cfg config.Config userID string } func newTestEnv(t *testing.T) *testEnv { t.Helper() dsn := os.Getenv("HAVEN_TEST_DB_DSN") if dsn == "" { t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过端到端测试") } admin, err := db.Open(t.Context(), dsn) if err != nil { t.Fatalf("连接测试库失败: %v", err) } schema := "it_" + strings.ToLower(strings.NewReplacer("/", "_").Replace(t.Name())) if len(schema) > 40 { schema = schema[:40] } schema += "_" + fmt.Sprintf("%d", time.Now().UnixNano()%1e9) ctx := t.Context() if _, err := admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil { t.Fatalf("清理 schema 失败: %v", err) } if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil { t.Fatalf("创建 schema 失败: %v", err) } // 在独立 schema 里建表:直接复用 migration 文件,保证与生产结构一致 migration, err := os.ReadFile("../../migrations/0001_init.up.sql") if err != nil { t.Fatalf("读取 migration 失败: %v", err) } conn, err := db.Open(ctx, appendSearchPath(dsn, schema+",public")) if err != nil { t.Fatalf("连接测试 schema 失败: %v", err) } t.Cleanup(func() { conn.Close() admin.ExecContext(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`) admin.Close() }) if _, err := conn.ExecContext(ctx, string(migration)); err != nil { t.Fatalf("建表失败: %v", err) } cfg := config.Config{ ListenAddr: "127.0.0.1:0", DBDSN: dsn, FrontendDir: t.TempDir(), GogsBaseURL: "https://gogs.example.com", CookieDomain: "", CookieSecure: false, TotpIssuer: "Haven", LogLevel: "error", } store := auth.NewSQLStore(conn) svc := auth.NewService(conn) h := auth.NewHandler(svc, store, cfg.TotpIssuer, cfg.CookieDomain, cfg.CookieSecure) auth.UseAudit(h, audit.NewWriter(conn)) return &testEnv{t: t, router: newRouter(h, store), store: store, svc: svc, cfg: cfg} } // appendSearchPath 在 DSN 追加 search_path,用于隔离集成测试的 schema。 func appendSearchPath(dsn, searchPath string) string { sep := "?" if strings.Contains(dsn, "?") { sep = "&" } return dsn + sep + "search_path=" + url.QueryEscape(searchPath) } // createUser 直接插库建用户。 func (e *testEnv) createUser(username, password string) string { e.t.Helper() hash, err := auth.HashPassword(password) if err != nil { e.t.Fatal(err) } var id string if err := e.store.DB().QueryRow( `INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`, username, hash).Scan(&id); err != nil { e.t.Fatalf("插入用户失败: %v", err) } e.userID = id return id } // do 发一个 JSON 请求,返回响应与 cookie。 func (e *testEnv) do(method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder { e.t.Helper() var reader *bytes.Reader if body == nil { reader = bytes.NewReader(nil) } else { raw, err := json.Marshal(body) if err != nil { e.t.Fatal(err) } reader = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") for _, c := range cookies { req.AddCookie(c) } w := httptest.NewRecorder() e.router.ServeHTTP(w, req) return w } func (e *testEnv) doWithToken(method, path, token string) *httptest.ResponseRecorder { e.t.Helper() req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) return w } func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range w.Result().Cookies() { if c.Name == auth.CookieSession && c.Value != "" { return c } } t.Fatal("响应里没有会话 cookie") return nil } func deviceCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range w.Result().Cookies() { if c.Name == auth.CookieTrustedDevice && c.Value != "" { return c } } return nil } // loginNoTotp 用账密登录并返回会话 cookie。 func (e *testEnv) loginNoTotp(username, password string) *http.Cookie { e.t.Helper() w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": username, "password": password}) if w.Code != http.StatusOK { e.t.Fatalf("登录失败,状态码 %d: %s", w.Code, w.Body.String()) } return sessionCookie(e.t, w) } // --- /api/me --- func TestMeAnonymous(t *testing.T) { e := newTestEnv(t) w := e.do(http.MethodGet, "/api/me", nil) if w.Code != http.StatusOK { t.Fatalf("状态码 = %d", w.Code) } var got map[string]any _ = json.Unmarshal(w.Body.Bytes(), &got) if got["anonymous"] != true { t.Errorf("匿名响应应含 anonymous=true,实际 %v", got) } } func TestMeSessionAndToken(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") cookie := e.loginNoTotp("kairo", "correct-horse-battery") w := e.do(http.MethodGet, "/api/me", nil, cookie) var got map[string]any _ = json.Unmarshal(w.Body.Bytes(), &got) if got["anonymous"] != false || got["auth_type"] != "session" { t.Errorf("会话身份响应异常: %v", got) } if _, ok := got["scope"]; ok { t.Error("会话用户不应返回 scope") } } // --- 登录 --- func TestLoginErrors(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") // 密码错误:401,且 message 不区分用户名/密码(api.md 2.1) w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "wrong"}) if w.Code != http.StatusUnauthorized { t.Errorf("密码错误状态码 = %d", w.Code) } wrongPwBody := w.Body.String() // 用户不存在:也是同样的 message,防枚举 w = e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "nobody", "password": "wrong"}) if w.Code != http.StatusUnauthorized { t.Errorf("用户不存在状态码 = %d", w.Code) } if w.Body.String() != wrongPwBody { t.Errorf("两种失败应返回相同响应防枚举: %q vs %q", wrongPwBody, w.Body.String()) } // 缺字段:400 w = e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo"}) if w.Code != http.StatusBadRequest { t.Errorf("缺字段状态码 = %d,期望 400", w.Code) } } func TestLoginWritesAudit(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "wrong"}) e.loginNoTotp("kairo", "correct-horse-battery") var count int var reason string if err := e.store.DB().QueryRow( `SELECT count(*) FROM audit_events WHERE event_type = 'login_failure'`).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Errorf("login_failure 条数 = %d", count) } if err := e.store.DB().QueryRow( `SELECT metadata->>'reason' FROM audit_events WHERE event_type = 'login_failure'`).Scan(&reason); err != nil { t.Fatal(err) } if reason != "bad_password" { t.Errorf("login_failure reason = %q", reason) } if err := e.store.DB().QueryRow( `SELECT count(*) FROM audit_events WHERE event_type = 'login_success'`).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Errorf("login_success 条数 = %d", count) } } // --- 会话管理 --- func TestSessionsRoutes(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") c1 := e.loginNoTotp("kairo", "correct-horse-battery") _ = e.loginNoTotp("kairo", "correct-horse-battery") // 列表 w := e.do(http.MethodGet, "/api/sessions", nil, c1) if w.Code != http.StatusOK { t.Fatalf("状态码 = %d", w.Code) } var list struct { Items []map[string]any `json:"items"` Total int `json:"total"` } _ = json.Unmarshal(w.Body.Bytes(), &list) if list.Total != 2 || len(list.Items) != 2 { t.Fatalf("应有 2 条会话,实际 %+v", list) } current := 0 for _, it := range list.Items { if it["is_current"] == true { current++ } } if current != 1 { t.Errorf("应恰好一条 is_current,实际 %d", current) } // 撤销另一台设备 var otherID string for _, it := range list.Items { if it["is_current"] != true { otherID, _ = it["id"].(string) } } if w := e.do(http.MethodDelete, "/api/sessions/"+otherID, nil, c1); w.Code != http.StatusNoContent { t.Errorf("撤销其他会话状态码 = %d", w.Code) } // 撤销当前会话:422 var currentID string for _, it := range list.Items { if it["is_current"] == true { currentID, _ = it["id"].(string) } } w = e.do(http.MethodDelete, "/api/sessions/"+currentID, nil, c1) if w.Code != http.StatusUnprocessableEntity { t.Errorf("撤销当前会话应 422,实际 %d", w.Code) } // Token 调用列表:403 createdTok, err := e.svc.CreateAPIToken(t.Context(), e.userID, "probe", auth.ScopeRead, nil) if err != nil { t.Fatal(err) } tok := createdTok.Token if w := e.doWithToken(http.MethodGet, "/api/sessions", tok); w.Code != http.StatusForbidden { t.Errorf("Token 查会话应 403,实际 %d", w.Code) } } func TestLogoutClearsSession(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") cookie := e.loginNoTotp("kairo", "correct-horse-battery") if w := e.do(http.MethodPost, "/api/auth/logout", nil, cookie); w.Code != http.StatusNoContent { t.Fatalf("登出状态码 = %d", w.Code) } // 旧 cookie 失效 w := e.do(http.MethodGet, "/api/me", nil, cookie) var got map[string]any _ = json.Unmarshal(w.Body.Bytes(), &got) if got["anonymous"] != true { t.Error("登出后旧会话 cookie 应失效") } var count int if err := e.store.DB().QueryRow(`SELECT count(*) FROM audit_events WHERE event_type = 'logout'`).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Errorf("应写一条 logout 审计,实际 %d", count) } } // --- Token 管理 --- func TestTokensRoutes(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") cookie := e.loginNoTotp("kairo", "correct-horse-battery") // 未启用 TOTP 的会话不能管理 Token:403(api.md 1.6) if w := e.do(http.MethodGet, "/api/tokens", nil, cookie); w.Code != http.StatusForbidden { t.Fatalf("未通过 TOTP 应 403,实际 %d", w.Code) } // 启用 TOTP 后,旧会话仍未「通过过」TOTP(auth.md 6.2),需重新登录走完整两步 secret := enableTotp(t, e, cookie) cookie = e.loginWithTotp("kairo", "correct-horse-battery", secret) // 创建 w := e.do(http.MethodPost, "/api/tokens", map[string]any{"name": "cli", "scope": "read"}, cookie) if w.Code != http.StatusCreated { t.Fatalf("创建 Token 状态码 = %d: %s", w.Code, w.Body.String()) } var created map[string]any _ = json.Unmarshal(w.Body.Bytes(), &created) plain, _ := created["token"].(string) if plain == "" { t.Fatal("创建响应应含一次性明文") } // 列表不返回明文 w = e.do(http.MethodGet, "/api/tokens", nil, cookie) if bytes.Contains(w.Body.Bytes(), []byte(plain)) { t.Error("列表不应返回 Token 明文") } if !bytes.Contains(w.Body.Bytes(), []byte("token_prefix")) { t.Error("列表应含 token_prefix") } // 非法 scope:422 if w := e.do(http.MethodPost, "/api/tokens", map[string]any{"name": "x", "scope": "admin"}, cookie); w.Code != http.StatusUnprocessableEntity { t.Errorf("非法 scope 应 422,实际 %d", w.Code) } // 用该 Token 访问只读接口 if w := e.doWithToken(http.MethodGet, "/api/me", plain); w.Code != http.StatusOK { t.Errorf("Token 调 /api/me 应 200,实际 %d", w.Code) } // 撤销 id, _ := created["id"].(string) if w := e.do(http.MethodDelete, "/api/tokens/"+id, nil, cookie); w.Code != http.StatusNoContent { t.Errorf("撤销 Token 状态码 = %d", w.Code) } // 已撤销的 Token 不能再用 if w := e.doWithToken(http.MethodGet, "/api/me", plain); w.Code != http.StatusUnauthorized { t.Errorf("已撤销 Token 应 401,实际 %d", w.Code) } // 重复撤销:422 if w := e.do(http.MethodDelete, "/api/tokens/"+id, nil, cookie); w.Code != http.StatusUnprocessableEntity { t.Errorf("重复撤销应 422,实际 %d", w.Code) } // 撤销是写 revoked_at,不删记录 var revokedAt *time.Time if err := e.store.DB().QueryRow(`SELECT revoked_at FROM tokens WHERE id = $1`, id).Scan(&revokedAt); err != nil { t.Fatal(err) } if revokedAt == nil { t.Error("撤销应写 revoked_at 且保留记录") } } // loginWithTotp 走完整两步登录,返回通过 TOTP 的会话 cookie。 func (e *testEnv) loginWithTotp(username, password, secret string) *http.Cookie { e.t.Helper() w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": username, "password": password}) if w.Code != http.StatusOK { e.t.Fatalf("第一步登录状态码 = %d", w.Code) } var step1 map[string]any _ = json.Unmarshal(w.Body.Bytes(), &step1) temp, _ := step1["temp_token"].(string) if temp == "" { e.t.Fatal("应返回 temp_token") } code, err := auth.GenerateTotpCode(secret, time.Now()) if err != nil { e.t.Fatal(err) } w = e.do(http.MethodPost, "/api/auth/login/totp", map[string]any{"temp_token": temp, "code": code}) if w.Code != http.StatusOK { e.t.Fatalf("第二步登录状态码 = %d: %s", w.Code, w.Body.String()) } return sessionCookie(e.t, w) } // enableTotp 走 setup + verify 两步,返回 secret。 func enableTotp(t *testing.T, e *testEnv, cookie *http.Cookie) string { t.Helper() w := e.do(http.MethodPost, "/api/auth/totp/setup", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("setup 状态码 = %d: %s", w.Code, w.Body.String()) } var setup map[string]any _ = json.Unmarshal(w.Body.Bytes(), &setup) secret, _ := setup["secret"].(string) if secret == "" { t.Fatal("setup 应返回 secret") } code, err := auth.GenerateTotpCode(secret, time.Now()) if err != nil { t.Fatal(err) } w = e.do(http.MethodPost, "/api/auth/totp/verify", map[string]any{"secret": secret, "code": code}, cookie) if w.Code != http.StatusOK { t.Fatalf("verify 状态码 = %d: %s", w.Code, w.Body.String()) } return secret } func TestTrustedDevicesRoutes(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") // 登录时请求授信设备 w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "correct-horse-battery", "trust_device": true}) if w.Code != http.StatusOK { t.Fatalf("登录状态码 = %d", w.Code) } cookie := sessionCookie(t, w) if deviceCookie(t, w) == nil { t.Fatal("trust_device=true 应下发授信设备 cookie") } // 列表 w = e.do(http.MethodGet, "/api/trusted-devices", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("状态码 = %d", w.Code) } var list struct { Items []map[string]any `json:"items"` Total int `json:"total"` } _ = json.Unmarshal(w.Body.Bytes(), &list) if list.Total != 1 { t.Fatalf("应有 1 台授信设备,实际 %+v", list) } // 不提供创建接口(api.md 4.2 说明) if w := e.do(http.MethodPost, "/api/trusted-devices", map[string]any{}, cookie); w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed { t.Errorf("不应存在创建设备接口,实际状态码 %d", w.Code) } // 撤销 id, _ := list.Items[0]["id"].(string) if w := e.do(http.MethodDelete, "/api/trusted-devices/"+id, nil, cookie); w.Code != http.StatusNoContent { t.Errorf("撤销设备状态码 = %d", w.Code) } var count int if err := e.store.DB().QueryRow(`SELECT count(*) FROM trusted_devices`).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Error("撤销授信设备应硬删除") } } func TestLoginWithTotpEndToEnd(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") cookie := e.loginNoTotp("kairo", "correct-horse-battery") secret := enableTotp(t, e, cookie) // 新登录要求 TOTP w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "correct-horse-battery"}) if w.Code != http.StatusOK { t.Fatalf("状态码 = %d", w.Code) } var step1 map[string]any _ = json.Unmarshal(w.Body.Bytes(), &step1) if step1["totp_required"] != true { t.Fatalf("应要求 TOTP: %v", step1) } if _, ok := step1["temp_token"]; !ok { t.Fatal("应返回 temp_token") } if len(w.Result().Cookies()) != 0 { t.Error("需要 TOTP 时不应下发会话 cookie") } tempToken, _ := step1["temp_token"].(string) // 错误码 if w := e.do(http.MethodPost, "/api/auth/login/totp", map[string]any{"temp_token": tempToken, "code": "000000"}); w.Code != http.StatusUnauthorized { t.Errorf("错误动态码应 401,实际 %d", w.Code) } code, _ := auth.GenerateTotpCode(secret, time.Now()) w = e.do(http.MethodPost, "/api/auth/login/totp", map[string]any{"temp_token": tempToken, "code": code}) if w.Code != http.StatusOK { t.Fatalf("第二步状态码 = %d: %s", w.Code, w.Body.String()) } if sessionCookie(t, w) == nil { t.Fatal("第二步成功应下发会话") } // 不再要求 TOTP 的会话:拒绝管理 Token,但可看会话列表 newCookie := sessionCookie(t, w) if w := e.do(http.MethodGet, "/api/sessions", nil, newCookie); w.Code != http.StatusOK { t.Errorf("会话列表状态码 = %d", w.Code) } } func TestPasswordChangeRequiresTotp(t *testing.T) { e := newTestEnv(t) e.createUser("kairo", "correct-horse-battery") cookie := e.loginNoTotp("kairo", "correct-horse-battery") // 未启用 TOTP 的会话改密:403(api.md 2.7 要求 登录 + TOTP) if w := e.do(http.MethodPost, "/api/auth/password", map[string]any{"old_password": "correct-horse-battery", "new_password": "brand-new-password"}, cookie); w.Code != http.StatusForbidden { t.Errorf("未通过 TOTP 改密应 403,实际 %d", w.Code) } } func TestUnknownRoute(t *testing.T) { e := newTestEnv(t) w := e.do(http.MethodGet, "/api/health", nil) if w.Code != http.StatusOK { t.Errorf("健康检查状态码 = %d", w.Code) } }