router_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. package main
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "net/http/httptest"
  9. "net/url"
  10. "os"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/gin-gonic/gin"
  15. "github.com/kairo/haven/internal/audit"
  16. "github.com/kairo/haven/internal/auth"
  17. "github.com/kairo/haven/internal/config"
  18. "github.com/kairo/haven/internal/db"
  19. )
  20. // 端到端测试:真实 PostgreSQL + 完整路由栈,逐条对照 docs/api.md。
  21. //
  22. // HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' \
  23. // go test ./cmd/haven -v
  24. type testEnv struct {
  25. t *testing.T
  26. router *gin.Engine
  27. store *auth.SQLStore
  28. svc *auth.Service
  29. cfg config.Config
  30. userID string
  31. }
  32. func newTestEnv(t *testing.T) *testEnv {
  33. t.Helper()
  34. dsn := os.Getenv("HAVEN_TEST_DB_DSN")
  35. if dsn == "" {
  36. t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过端到端测试")
  37. }
  38. admin, err := db.Open(t.Context(), dsn)
  39. if err != nil {
  40. t.Fatalf("连接测试库失败: %v", err)
  41. }
  42. schema := "it_" + strings.ToLower(strings.NewReplacer("/", "_").Replace(t.Name()))
  43. if len(schema) > 40 {
  44. schema = schema[:40]
  45. }
  46. schema += "_" + fmt.Sprintf("%d", time.Now().UnixNano()%1e9)
  47. ctx := t.Context()
  48. if _, err := admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil {
  49. t.Fatalf("清理 schema 失败: %v", err)
  50. }
  51. if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil {
  52. t.Fatalf("创建 schema 失败: %v", err)
  53. }
  54. // 在独立 schema 里建表:直接复用 migration 文件,保证与生产结构一致
  55. migration, err := os.ReadFile("../../migrations/0001_init.up.sql")
  56. if err != nil {
  57. t.Fatalf("读取 migration 失败: %v", err)
  58. }
  59. conn, err := db.Open(ctx, appendSearchPath(dsn, schema+",public"))
  60. if err != nil {
  61. t.Fatalf("连接测试 schema 失败: %v", err)
  62. }
  63. t.Cleanup(func() {
  64. conn.Close()
  65. admin.ExecContext(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`)
  66. admin.Close()
  67. })
  68. if _, err := conn.ExecContext(ctx, string(migration)); err != nil {
  69. t.Fatalf("建表失败: %v", err)
  70. }
  71. cfg := config.Config{
  72. ListenAddr: "127.0.0.1:0",
  73. DBDSN: dsn,
  74. FrontendDir: t.TempDir(),
  75. GogsBaseURL: "https://gogs.example.com",
  76. CookieDomain: "",
  77. CookieSecure: false,
  78. TotpIssuer: "Haven",
  79. LogLevel: "error",
  80. }
  81. store := auth.NewSQLStore(conn)
  82. svc := auth.NewService(conn)
  83. h := auth.NewHandler(svc, store, cfg.TotpIssuer, cfg.CookieDomain, cfg.CookieSecure)
  84. auth.UseAudit(h, audit.NewWriter(conn))
  85. return &testEnv{t: t, router: newRouter(h, store), store: store, svc: svc, cfg: cfg}
  86. }
  87. // appendSearchPath 在 DSN 追加 search_path,用于隔离集成测试的 schema。
  88. func appendSearchPath(dsn, searchPath string) string {
  89. sep := "?"
  90. if strings.Contains(dsn, "?") {
  91. sep = "&"
  92. }
  93. return dsn + sep + "search_path=" + url.QueryEscape(searchPath)
  94. }
  95. // createUser 直接插库建用户。
  96. func (e *testEnv) createUser(username, password string) string {
  97. e.t.Helper()
  98. hash, err := auth.HashPassword(password)
  99. if err != nil {
  100. e.t.Fatal(err)
  101. }
  102. var id string
  103. if err := e.store.DB().QueryRow(
  104. `INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
  105. username, hash).Scan(&id); err != nil {
  106. e.t.Fatalf("插入用户失败: %v", err)
  107. }
  108. e.userID = id
  109. return id
  110. }
  111. // do 发一个 JSON 请求,返回响应与 cookie。
  112. func (e *testEnv) do(method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder {
  113. e.t.Helper()
  114. var reader *bytes.Reader
  115. if body == nil {
  116. reader = bytes.NewReader(nil)
  117. } else {
  118. raw, err := json.Marshal(body)
  119. if err != nil {
  120. e.t.Fatal(err)
  121. }
  122. reader = bytes.NewReader(raw)
  123. }
  124. req := httptest.NewRequest(method, path, reader)
  125. req.Header.Set("Content-Type", "application/json")
  126. for _, c := range cookies {
  127. req.AddCookie(c)
  128. }
  129. w := httptest.NewRecorder()
  130. e.router.ServeHTTP(w, req)
  131. return w
  132. }
  133. func (e *testEnv) doWithToken(method, path, token string) *httptest.ResponseRecorder {
  134. e.t.Helper()
  135. req := httptest.NewRequest(method, path, nil)
  136. req.Header.Set("Authorization", "Bearer "+token)
  137. w := httptest.NewRecorder()
  138. e.router.ServeHTTP(w, req)
  139. return w
  140. }
  141. func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
  142. t.Helper()
  143. for _, c := range w.Result().Cookies() {
  144. if c.Name == auth.CookieSession && c.Value != "" {
  145. return c
  146. }
  147. }
  148. t.Fatal("响应里没有会话 cookie")
  149. return nil
  150. }
  151. func deviceCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
  152. t.Helper()
  153. for _, c := range w.Result().Cookies() {
  154. if c.Name == auth.CookieTrustedDevice && c.Value != "" {
  155. return c
  156. }
  157. }
  158. return nil
  159. }
  160. // loginNoTotp 用账密登录并返回会话 cookie。
  161. func (e *testEnv) loginNoTotp(username, password string) *http.Cookie {
  162. e.t.Helper()
  163. w := e.do(http.MethodPost, "/api/auth/login",
  164. map[string]any{"username": username, "password": password})
  165. if w.Code != http.StatusOK {
  166. e.t.Fatalf("登录失败,状态码 %d: %s", w.Code, w.Body.String())
  167. }
  168. return sessionCookie(e.t, w)
  169. }
  170. // --- /api/me ---
  171. func TestMeAnonymous(t *testing.T) {
  172. e := newTestEnv(t)
  173. w := e.do(http.MethodGet, "/api/me", nil)
  174. if w.Code != http.StatusOK {
  175. t.Fatalf("状态码 = %d", w.Code)
  176. }
  177. var got map[string]any
  178. _ = json.Unmarshal(w.Body.Bytes(), &got)
  179. if got["anonymous"] != true {
  180. t.Errorf("匿名响应应含 anonymous=true,实际 %v", got)
  181. }
  182. }
  183. func TestMeSessionAndToken(t *testing.T) {
  184. e := newTestEnv(t)
  185. e.createUser("kairo", "correct-horse-battery")
  186. cookie := e.loginNoTotp("kairo", "correct-horse-battery")
  187. w := e.do(http.MethodGet, "/api/me", nil, cookie)
  188. var got map[string]any
  189. _ = json.Unmarshal(w.Body.Bytes(), &got)
  190. if got["anonymous"] != false || got["auth_type"] != "session" {
  191. t.Errorf("会话身份响应异常: %v", got)
  192. }
  193. if _, ok := got["scope"]; ok {
  194. t.Error("会话用户不应返回 scope")
  195. }
  196. }
  197. // --- 登录 ---
  198. func TestLoginErrors(t *testing.T) {
  199. e := newTestEnv(t)
  200. e.createUser("kairo", "correct-horse-battery")
  201. // 密码错误:401,且 message 不区分用户名/密码(api.md 2.1)
  202. w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "wrong"})
  203. if w.Code != http.StatusUnauthorized {
  204. t.Errorf("密码错误状态码 = %d", w.Code)
  205. }
  206. wrongPwBody := w.Body.String()
  207. // 用户不存在:也是同样的 message,防枚举
  208. w = e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "nobody", "password": "wrong"})
  209. if w.Code != http.StatusUnauthorized {
  210. t.Errorf("用户不存在状态码 = %d", w.Code)
  211. }
  212. if w.Body.String() != wrongPwBody {
  213. t.Errorf("两种失败应返回相同响应防枚举: %q vs %q", wrongPwBody, w.Body.String())
  214. }
  215. // 缺字段:400
  216. w = e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo"})
  217. if w.Code != http.StatusBadRequest {
  218. t.Errorf("缺字段状态码 = %d,期望 400", w.Code)
  219. }
  220. }
  221. func TestLoginWritesAudit(t *testing.T) {
  222. e := newTestEnv(t)
  223. e.createUser("kairo", "correct-horse-battery")
  224. e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "wrong"})
  225. e.loginNoTotp("kairo", "correct-horse-battery")
  226. var count int
  227. var reason string
  228. if err := e.store.DB().QueryRow(
  229. `SELECT count(*) FROM audit_events WHERE event_type = 'login_failure'`).Scan(&count); err != nil {
  230. t.Fatal(err)
  231. }
  232. if count != 1 {
  233. t.Errorf("login_failure 条数 = %d", count)
  234. }
  235. if err := e.store.DB().QueryRow(
  236. `SELECT metadata->>'reason' FROM audit_events WHERE event_type = 'login_failure'`).Scan(&reason); err != nil {
  237. t.Fatal(err)
  238. }
  239. if reason != "bad_password" {
  240. t.Errorf("login_failure reason = %q", reason)
  241. }
  242. if err := e.store.DB().QueryRow(
  243. `SELECT count(*) FROM audit_events WHERE event_type = 'login_success'`).Scan(&count); err != nil {
  244. t.Fatal(err)
  245. }
  246. if count != 1 {
  247. t.Errorf("login_success 条数 = %d", count)
  248. }
  249. }
  250. // --- 会话管理 ---
  251. func TestSessionsRoutes(t *testing.T) {
  252. e := newTestEnv(t)
  253. e.createUser("kairo", "correct-horse-battery")
  254. c1 := e.loginNoTotp("kairo", "correct-horse-battery")
  255. _ = e.loginNoTotp("kairo", "correct-horse-battery")
  256. // 列表
  257. w := e.do(http.MethodGet, "/api/sessions", nil, c1)
  258. if w.Code != http.StatusOK {
  259. t.Fatalf("状态码 = %d", w.Code)
  260. }
  261. var list struct {
  262. Items []map[string]any `json:"items"`
  263. Total int `json:"total"`
  264. }
  265. _ = json.Unmarshal(w.Body.Bytes(), &list)
  266. if list.Total != 2 || len(list.Items) != 2 {
  267. t.Fatalf("应有 2 条会话,实际 %+v", list)
  268. }
  269. current := 0
  270. for _, it := range list.Items {
  271. if it["is_current"] == true {
  272. current++
  273. }
  274. }
  275. if current != 1 {
  276. t.Errorf("应恰好一条 is_current,实际 %d", current)
  277. }
  278. // 撤销另一台设备
  279. var otherID string
  280. for _, it := range list.Items {
  281. if it["is_current"] != true {
  282. otherID, _ = it["id"].(string)
  283. }
  284. }
  285. if w := e.do(http.MethodDelete, "/api/sessions/"+otherID, nil, c1); w.Code != http.StatusNoContent {
  286. t.Errorf("撤销其他会话状态码 = %d", w.Code)
  287. }
  288. // 撤销当前会话:422
  289. var currentID string
  290. for _, it := range list.Items {
  291. if it["is_current"] == true {
  292. currentID, _ = it["id"].(string)
  293. }
  294. }
  295. w = e.do(http.MethodDelete, "/api/sessions/"+currentID, nil, c1)
  296. if w.Code != http.StatusUnprocessableEntity {
  297. t.Errorf("撤销当前会话应 422,实际 %d", w.Code)
  298. }
  299. // Token 调用列表:403
  300. createdTok, err := e.svc.CreateAPIToken(t.Context(), e.userID, "probe", auth.ScopeRead, nil)
  301. if err != nil {
  302. t.Fatal(err)
  303. }
  304. tok := createdTok.Token
  305. if w := e.doWithToken(http.MethodGet, "/api/sessions", tok); w.Code != http.StatusForbidden {
  306. t.Errorf("Token 查会话应 403,实际 %d", w.Code)
  307. }
  308. }
  309. func TestLogoutClearsSession(t *testing.T) {
  310. e := newTestEnv(t)
  311. e.createUser("kairo", "correct-horse-battery")
  312. cookie := e.loginNoTotp("kairo", "correct-horse-battery")
  313. if w := e.do(http.MethodPost, "/api/auth/logout", nil, cookie); w.Code != http.StatusNoContent {
  314. t.Fatalf("登出状态码 = %d", w.Code)
  315. }
  316. // 旧 cookie 失效
  317. w := e.do(http.MethodGet, "/api/me", nil, cookie)
  318. var got map[string]any
  319. _ = json.Unmarshal(w.Body.Bytes(), &got)
  320. if got["anonymous"] != true {
  321. t.Error("登出后旧会话 cookie 应失效")
  322. }
  323. var count int
  324. if err := e.store.DB().QueryRow(`SELECT count(*) FROM audit_events WHERE event_type = 'logout'`).Scan(&count); err != nil {
  325. t.Fatal(err)
  326. }
  327. if count != 1 {
  328. t.Errorf("应写一条 logout 审计,实际 %d", count)
  329. }
  330. }
  331. // --- Token 管理 ---
  332. func TestTokensRoutes(t *testing.T) {
  333. e := newTestEnv(t)
  334. e.createUser("kairo", "correct-horse-battery")
  335. cookie := e.loginNoTotp("kairo", "correct-horse-battery")
  336. // 未启用 TOTP 的会话不能管理 Token:403(api.md 1.6)
  337. if w := e.do(http.MethodGet, "/api/tokens", nil, cookie); w.Code != http.StatusForbidden {
  338. t.Fatalf("未通过 TOTP 应 403,实际 %d", w.Code)
  339. }
  340. // 启用 TOTP 后,旧会话仍未「通过过」TOTP(auth.md 6.2),需重新登录走完整两步
  341. secret := enableTotp(t, e, cookie)
  342. cookie = e.loginWithTotp("kairo", "correct-horse-battery", secret)
  343. // 创建
  344. w := e.do(http.MethodPost, "/api/tokens",
  345. map[string]any{"name": "cli", "scope": "read"}, cookie)
  346. if w.Code != http.StatusCreated {
  347. t.Fatalf("创建 Token 状态码 = %d: %s", w.Code, w.Body.String())
  348. }
  349. var created map[string]any
  350. _ = json.Unmarshal(w.Body.Bytes(), &created)
  351. plain, _ := created["token"].(string)
  352. if plain == "" {
  353. t.Fatal("创建响应应含一次性明文")
  354. }
  355. // 列表不返回明文
  356. w = e.do(http.MethodGet, "/api/tokens", nil, cookie)
  357. if bytes.Contains(w.Body.Bytes(), []byte(plain)) {
  358. t.Error("列表不应返回 Token 明文")
  359. }
  360. if !bytes.Contains(w.Body.Bytes(), []byte("token_prefix")) {
  361. t.Error("列表应含 token_prefix")
  362. }
  363. // 非法 scope:422
  364. if w := e.do(http.MethodPost, "/api/tokens",
  365. map[string]any{"name": "x", "scope": "admin"}, cookie); w.Code != http.StatusUnprocessableEntity {
  366. t.Errorf("非法 scope 应 422,实际 %d", w.Code)
  367. }
  368. // 用该 Token 访问只读接口
  369. if w := e.doWithToken(http.MethodGet, "/api/me", plain); w.Code != http.StatusOK {
  370. t.Errorf("Token 调 /api/me 应 200,实际 %d", w.Code)
  371. }
  372. // 撤销
  373. id, _ := created["id"].(string)
  374. if w := e.do(http.MethodDelete, "/api/tokens/"+id, nil, cookie); w.Code != http.StatusNoContent {
  375. t.Errorf("撤销 Token 状态码 = %d", w.Code)
  376. }
  377. // 已撤销的 Token 不能再用
  378. if w := e.doWithToken(http.MethodGet, "/api/me", plain); w.Code != http.StatusUnauthorized {
  379. t.Errorf("已撤销 Token 应 401,实际 %d", w.Code)
  380. }
  381. // 重复撤销:422
  382. if w := e.do(http.MethodDelete, "/api/tokens/"+id, nil, cookie); w.Code != http.StatusUnprocessableEntity {
  383. t.Errorf("重复撤销应 422,实际 %d", w.Code)
  384. }
  385. // 撤销是写 revoked_at,不删记录
  386. var revokedAt *time.Time
  387. if err := e.store.DB().QueryRow(`SELECT revoked_at FROM tokens WHERE id = $1`, id).Scan(&revokedAt); err != nil {
  388. t.Fatal(err)
  389. }
  390. if revokedAt == nil {
  391. t.Error("撤销应写 revoked_at 且保留记录")
  392. }
  393. }
  394. // loginWithTotp 走完整两步登录,返回通过 TOTP 的会话 cookie。
  395. func (e *testEnv) loginWithTotp(username, password, secret string) *http.Cookie {
  396. e.t.Helper()
  397. w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": username, "password": password})
  398. if w.Code != http.StatusOK {
  399. e.t.Fatalf("第一步登录状态码 = %d", w.Code)
  400. }
  401. var step1 map[string]any
  402. _ = json.Unmarshal(w.Body.Bytes(), &step1)
  403. temp, _ := step1["temp_token"].(string)
  404. if temp == "" {
  405. e.t.Fatal("应返回 temp_token")
  406. }
  407. code, err := auth.GenerateTotpCode(secret, time.Now())
  408. if err != nil {
  409. e.t.Fatal(err)
  410. }
  411. w = e.do(http.MethodPost, "/api/auth/login/totp", map[string]any{"temp_token": temp, "code": code})
  412. if w.Code != http.StatusOK {
  413. e.t.Fatalf("第二步登录状态码 = %d: %s", w.Code, w.Body.String())
  414. }
  415. return sessionCookie(e.t, w)
  416. }
  417. // enableTotp 走 setup + verify 两步,返回 secret。
  418. func enableTotp(t *testing.T, e *testEnv, cookie *http.Cookie) string {
  419. t.Helper()
  420. w := e.do(http.MethodPost, "/api/auth/totp/setup", nil, cookie)
  421. if w.Code != http.StatusOK {
  422. t.Fatalf("setup 状态码 = %d: %s", w.Code, w.Body.String())
  423. }
  424. var setup map[string]any
  425. _ = json.Unmarshal(w.Body.Bytes(), &setup)
  426. secret, _ := setup["secret"].(string)
  427. if secret == "" {
  428. t.Fatal("setup 应返回 secret")
  429. }
  430. code, err := auth.GenerateTotpCode(secret, time.Now())
  431. if err != nil {
  432. t.Fatal(err)
  433. }
  434. w = e.do(http.MethodPost, "/api/auth/totp/verify",
  435. map[string]any{"secret": secret, "code": code}, cookie)
  436. if w.Code != http.StatusOK {
  437. t.Fatalf("verify 状态码 = %d: %s", w.Code, w.Body.String())
  438. }
  439. return secret
  440. }
  441. func TestTrustedDevicesRoutes(t *testing.T) {
  442. e := newTestEnv(t)
  443. e.createUser("kairo", "correct-horse-battery")
  444. // 登录时请求授信设备
  445. w := e.do(http.MethodPost, "/api/auth/login",
  446. map[string]any{"username": "kairo", "password": "correct-horse-battery", "trust_device": true})
  447. if w.Code != http.StatusOK {
  448. t.Fatalf("登录状态码 = %d", w.Code)
  449. }
  450. cookie := sessionCookie(t, w)
  451. if deviceCookie(t, w) == nil {
  452. t.Fatal("trust_device=true 应下发授信设备 cookie")
  453. }
  454. // 列表
  455. w = e.do(http.MethodGet, "/api/trusted-devices", nil, cookie)
  456. if w.Code != http.StatusOK {
  457. t.Fatalf("状态码 = %d", w.Code)
  458. }
  459. var list struct {
  460. Items []map[string]any `json:"items"`
  461. Total int `json:"total"`
  462. }
  463. _ = json.Unmarshal(w.Body.Bytes(), &list)
  464. if list.Total != 1 {
  465. t.Fatalf("应有 1 台授信设备,实际 %+v", list)
  466. }
  467. // 不提供创建接口(api.md 4.2 说明)
  468. if w := e.do(http.MethodPost, "/api/trusted-devices", map[string]any{}, cookie); w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed {
  469. t.Errorf("不应存在创建设备接口,实际状态码 %d", w.Code)
  470. }
  471. // 撤销
  472. id, _ := list.Items[0]["id"].(string)
  473. if w := e.do(http.MethodDelete, "/api/trusted-devices/"+id, nil, cookie); w.Code != http.StatusNoContent {
  474. t.Errorf("撤销设备状态码 = %d", w.Code)
  475. }
  476. var count int
  477. if err := e.store.DB().QueryRow(`SELECT count(*) FROM trusted_devices`).Scan(&count); err != nil {
  478. t.Fatal(err)
  479. }
  480. if count != 0 {
  481. t.Error("撤销授信设备应硬删除")
  482. }
  483. }
  484. func TestLoginWithTotpEndToEnd(t *testing.T) {
  485. e := newTestEnv(t)
  486. e.createUser("kairo", "correct-horse-battery")
  487. cookie := e.loginNoTotp("kairo", "correct-horse-battery")
  488. secret := enableTotp(t, e, cookie)
  489. // 新登录要求 TOTP
  490. w := e.do(http.MethodPost, "/api/auth/login",
  491. map[string]any{"username": "kairo", "password": "correct-horse-battery"})
  492. if w.Code != http.StatusOK {
  493. t.Fatalf("状态码 = %d", w.Code)
  494. }
  495. var step1 map[string]any
  496. _ = json.Unmarshal(w.Body.Bytes(), &step1)
  497. if step1["totp_required"] != true {
  498. t.Fatalf("应要求 TOTP: %v", step1)
  499. }
  500. if _, ok := step1["temp_token"]; !ok {
  501. t.Fatal("应返回 temp_token")
  502. }
  503. if len(w.Result().Cookies()) != 0 {
  504. t.Error("需要 TOTP 时不应下发会话 cookie")
  505. }
  506. tempToken, _ := step1["temp_token"].(string)
  507. // 错误码
  508. if w := e.do(http.MethodPost, "/api/auth/login/totp",
  509. map[string]any{"temp_token": tempToken, "code": "000000"}); w.Code != http.StatusUnauthorized {
  510. t.Errorf("错误动态码应 401,实际 %d", w.Code)
  511. }
  512. code, _ := auth.GenerateTotpCode(secret, time.Now())
  513. w = e.do(http.MethodPost, "/api/auth/login/totp",
  514. map[string]any{"temp_token": tempToken, "code": code})
  515. if w.Code != http.StatusOK {
  516. t.Fatalf("第二步状态码 = %d: %s", w.Code, w.Body.String())
  517. }
  518. if sessionCookie(t, w) == nil {
  519. t.Fatal("第二步成功应下发会话")
  520. }
  521. // 不再要求 TOTP 的会话:拒绝管理 Token,但可看会话列表
  522. newCookie := sessionCookie(t, w)
  523. if w := e.do(http.MethodGet, "/api/sessions", nil, newCookie); w.Code != http.StatusOK {
  524. t.Errorf("会话列表状态码 = %d", w.Code)
  525. }
  526. }
  527. func TestPasswordChangeRequiresTotp(t *testing.T) {
  528. e := newTestEnv(t)
  529. e.createUser("kairo", "correct-horse-battery")
  530. cookie := e.loginNoTotp("kairo", "correct-horse-battery")
  531. // 未启用 TOTP 的会话改密:403(api.md 2.7 要求 登录 + TOTP)
  532. if w := e.do(http.MethodPost, "/api/auth/password",
  533. map[string]any{"old_password": "correct-horse-battery", "new_password": "brand-new-password"}, cookie); w.Code != http.StatusForbidden {
  534. t.Errorf("未通过 TOTP 改密应 403,实际 %d", w.Code)
  535. }
  536. }
  537. func TestUnknownRoute(t *testing.T) {
  538. e := newTestEnv(t)
  539. w := e.do(http.MethodGet, "/api/health", nil)
  540. if w.Code != http.StatusOK {
  541. t.Errorf("健康检查状态码 = %d", w.Code)
  542. }
  543. }
苏ICP备2026082406号
苏公网安备 待审核