package httpapi_test import ( "fmt" "io" "net/http" "net/url" "strings" "testing" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" ) // countUserSessions 数某账号在库里的会话行。 func countUserSessions(t *testing.T, env *testEnv, userID int64) int { t.Helper() var n int if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil { t.Fatalf("统计会话: %v", err) } return n } // meRole 走 /api/auth/me 读当前身份:会话被撤之后这里会退回 guest。 func meRole(t *testing.T, env *testEnv, client *http.Client) string { t.Helper() status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false) if status != http.StatusOK || !body.OK { t.Fatalf("读 /api/auth/me 应 200,得到 %d %+v", status, body.Error) } return decodeData[map[string]any](t, body)["role"].(string) } // 会话失效的三条管理动作(重置密码 / 停用 / 自己改密)必须**当场**生效: // 旧 Cookie 下一次请求就该被当成访客,库里那行会话也该没了。 // 在此之前这些承诺只有 auth 包内的函数级断言,没有一条走 HTTP 的端到端用例—— // 管理端 handler 漏调撤会话(或撤了却忘了清 Cookie)不会有任何东西变红。 func TestAdminActionsInvalidateSessionsImmediately(t *testing.T) { env := newTestEnv(t) admin := adminClient(t, env) uid := env.createUser("recorder", "recorder-pass", "recorder") worker, _ := env.client() env.login(worker, "recorder", "recorder-pass") if got := meRole(t, env, worker); got != "recorder" { t.Fatalf("登录后身份应为 recorder,得到 %q", got) } // ① 管理员重置密码:旧会话立即失效(旧 Cookie 直接当访客用),库里的行也没了。 status, body := env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", uid), map[string]any{"password": "new-recorder-pass"}, true) if status != http.StatusOK || !body.OK { t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error) } if n := countUserSessions(t, env, uid); n != 0 { t.Fatalf("重置密码后该用户的会话行应清空,库里还剩 %d 行", n) } if got := meRole(t, env, worker); got != "guest" { t.Fatalf("重置密码后旧会话应退回 guest,得到 %q", got) } status, body = env.do(worker, http.MethodPost, "/api/teams", map[string]any{"code": "X1", "name": "旧会话不该还能写"}, true) if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" { t.Fatalf("重置密码后旧会话的写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error) } // 旧密码登不进去,新密码可以:证明失效的是会话,不是账号。 fresh, _ := env.client() if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login", map[string]any{"username": "recorder", "password": "recorder-pass"}, false); status == http.StatusOK { t.Fatalf("重置密码后旧密码不该还能登录,得到 %d", status) } env.login(fresh, "recorder", "new-recorder-pass") if got := meRole(t, env, fresh); got != "recorder" { t.Fatalf("新密码登录后身份应为 recorder,得到 %q", got) } // ② 停用账号:刚建好的这条新会话也必须当场失效。 status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", uid), map[string]any{"active": false}, true) if status != http.StatusOK || !body.OK { t.Fatalf("停用账号应 200,得到 %d %+v", status, body.Error) } if n := countUserSessions(t, env, uid); n != 0 { t.Fatalf("停用后该用户的会话行应清空,库里还剩 %d 行", n) } if got := meRole(t, env, fresh); got != "guest" { t.Fatalf("停用后旧会话应退回 guest,得到 %q", got) } if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login", map[string]any{"username": "recorder", "password": "new-recorder-pass"}, false); status == http.StatusOK { t.Fatalf("停用后不该还能登录,得到 %d", status) } } // 自己改密:本人的会话也要当场作废,并明确告诉前端「重新登录」。 // 只清 Cookie 不撤会话(或反过来)都算没做到——前者旧 Cookie 还能用,后者页面看着没退。 func TestChangeOwnPasswordInvalidatesOwnSession(t *testing.T) { env := newTestEnv(t) uid := env.createUser("recorder", "recorder-pass", "recorder") client, jar := env.client() env.login(client, "recorder", "recorder-pass") // 改密前先把旧会话 token 记下来:改密之后要用它重放(模拟「Cookie 被抄走/浏览器还没收到清除指令」)。 u, err := url.Parse(env.server.URL) if err != nil { t.Fatalf("解析测试服务地址: %v", err) } oldSession := "" for _, c := range jar.Cookies(u) { if c.Name == auth.CookieSession { oldSession = c.Value } } if oldSession == "" { t.Fatalf("登录后 Cookie 里应有 %s", auth.CookieSession) } status, body := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{"currentPassword": "recorder-pass", "newPassword": "brand-new-pass"}, true) if status != http.StatusOK || !body.OK { t.Fatalf("改密应 200,得到 %d %+v", status, body.Error) } if relogin, _ := decodeData[map[string]any](t, body)["relogin"].(bool); !relogin { t.Fatalf("改密回执应带 relogin=true 引导重新登录,得到 %+v", decodeData[map[string]any](t, body)) } if n := countUserSessions(t, env, uid); n != 0 { t.Fatalf("改密后自己的会话行应清空,库里还剩 %d 行", n) } // 服务端下发了删除指令:浏览器不会再带着这枚 Cookie 发请求。 for _, c := range jar.Cookies(u) { if c.Name == auth.CookieSession && c.Value != "" && c.MaxAge >= 0 { t.Fatalf("改密应答应清掉会话 Cookie,得到 %+v", c) } } // 旧 Cookie 手工塞回来重放也是访客:撤会话与清 Cookie 两件事必须都做到, // 只做后者的话,任何抓到过旧 Cookie 的地方都还能继续用这个身份。 replay, err := http.NewRequest(http.MethodGet, env.server.URL+"/api/auth/me", nil) if err != nil { t.Fatalf("构造重放请求: %v", err) } replay.AddCookie(&http.Cookie{Name: auth.CookieSession, Value: oldSession}) resp, err := (&http.Client{}).Do(replay) if err != nil { t.Fatalf("重放旧 Cookie: %v", err) } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if !strings.Contains(string(raw), `"role":"guest"`) { t.Fatalf("旧 Cookie 重放应被当成访客,得到 %d %s", resp.StatusCode, raw) } } // 滑动续期必须带着 Cookie 的 Max-Age 一起走,并且 Max-Age 跟的是**该会话的剩余寿命**, // 不是当前有效期:有效期被改短之后,老会话仍按自己的到期时间下发(否则浏览器比服务端早丢 Cookie)。 func TestMeRefreshesCookieMaxAgeFromSessionExpiry(t *testing.T) { env := newTestEnv(t) client := adminClient(t, env) // 登录时会话到期 = 登录时刻 + 24h;把有效期改短成 1 小时(不动已存在的会话)。 if status, body := env.do(client, http.MethodPut, "/api/system/session", map[string]any{"sessionHours": 1}, true); status != http.StatusOK { t.Fatalf("把有效期改成 1 小时应 200,得到 %d %+v", status, body.Error) } stored := sessionExpiry(t, env, "admin") resp, err := client.Get(env.server.URL + "/api/auth/me") if err != nil { t.Fatalf("请求 /auth/me: %v", err) } defer func() { _ = resp.Body.Close() }() _, _ = io.Copy(io.Discard, resp.Body) var session, csrf *http.Cookie for _, c := range resp.Cookies() { switch c.Name { case auth.CookieSession: session = c case auth.CookieCSRF: csrf = c } } if session == nil || csrf == nil { t.Fatalf("/auth/me 应重发会话与 CSRF Cookie(滑动续期要同步浏览器端的 Max-Age),得到会话 %+v / CSRF %+v", session, csrf) } // 剩余寿命 = 库里那条会话的到期时间 - 现在(容许几秒误差)。 want := int(time.Until(stored).Seconds()) if diff := session.MaxAge - want; diff > 3 || diff < -3 { t.Fatalf("会话 Cookie 的 Max-Age 应等于这条会话的剩余寿命 %d 秒,得到 %d", want, session.MaxAge) } // 关键对照:有效期已经改成 1 小时了,Max-Age 不能跟着它变成 3600。 if session.MaxAge < 20*60*60 { t.Fatalf("Max-Age 跟错了对象(疑似用了当前有效期而非会话到期时间):%d,库里到期时间是 %s", session.MaxAge, stored.Format(time.RFC3339)) } // CSRF 不轮换:值必须与 Cookie 里原样一致,否则飞行中的写请求会集体 403。 if _, err := url.Parse(env.server.URL); err != nil { t.Fatalf("解析测试服务地址: %v", err) } if csrf.MaxAge != session.MaxAge { t.Fatalf("CSRF Cookie 应与会话 Cookie 同一期限,得到 %d / %d", csrf.MaxAge, session.MaxAge) } if strings.TrimSpace(csrf.Value) == "" { t.Fatalf("重发的 CSRF Cookie 不该是空值") } }