主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
287 lines
9.4 KiB
Go
287 lines
9.4 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
)
|
|
|
|
// newRefreshEnv 构建一个网关,后端是真实 JWT 服务与真实的
|
|
// SQLite refresh 存储,并返回该存储与 DB,使测试能断言
|
|
// 持久化了什么。DB 每个测试都隔离在内存中。
|
|
func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
sqlDB, err := db.Open(ctx, ":memory:", nil)
|
|
if err != nil {
|
|
t.Fatalf("open in-memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
|
|
t.Fatalf("migrate up: %v", err)
|
|
}
|
|
refresh, err := auth.NewRefreshStore(ctx, sqlDB, nil)
|
|
if err != nil {
|
|
t.Fatalf("new refresh store: %v", err)
|
|
}
|
|
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
|
b := bus.New()
|
|
cfg := &config.Config{Auth: config.AuthConfig{
|
|
AdminUser: "admin",
|
|
AdminPass: "secret",
|
|
JWTSecret: testSecret,
|
|
RefreshTTL: time.Hour,
|
|
}}
|
|
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Refresh: refresh})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}, refresh, sqlDB
|
|
}
|
|
|
|
// decodeData 把 {data: ...} 信封载荷反序列化进 v。
|
|
func decodeData(t *testing.T, body []byte, v any) {
|
|
t.Helper()
|
|
env := decodeBody(t, body)
|
|
raw, ok := env["data"]
|
|
if !ok {
|
|
t.Fatalf("response has no data envelope: %s", body)
|
|
}
|
|
if err := json.Unmarshal(raw, v); err != nil {
|
|
t.Fatalf("decode data %s: %v", body, err)
|
|
}
|
|
}
|
|
|
|
// loginRefresh 执行一次管理登录并返回访问令牌与 refresh token。
|
|
func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) {
|
|
t.Helper()
|
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login",
|
|
map[string]string{"username": "admin", "password": "secret"}, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login status = %d, want 200: %s", resp.StatusCode, body)
|
|
}
|
|
var data struct {
|
|
Token string `json:"token"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
Role string `json:"role"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
}
|
|
decodeData(t, body, &data)
|
|
if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" {
|
|
t.Fatalf("login payload = %+v, want token/expiresAt/role=owner", data)
|
|
}
|
|
if data.RefreshToken == "" {
|
|
t.Fatalf("login payload has no refreshToken: %s", body)
|
|
}
|
|
return data.Token, data.RefreshToken
|
|
}
|
|
|
|
// postRefresh 用 token 调用 /auth/refresh 并返回状态与响应体。
|
|
func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) {
|
|
t.Helper()
|
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/refresh",
|
|
map[string]string{"refreshToken": token}, "")
|
|
return resp.StatusCode, body
|
|
}
|
|
|
|
// wantInvalidGrant 断言 401 与机器可读的 invalid_grant 码。
|
|
func wantInvalidGrant(t *testing.T, status int, body []byte) {
|
|
t.Helper()
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401: %s", status, body)
|
|
}
|
|
env := decodeBody(t, body)
|
|
var eobj struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(env["error"], &eobj); err != nil {
|
|
t.Fatalf("error envelope: %v (%s)", err, body)
|
|
}
|
|
if eobj.Code != "invalid_grant" {
|
|
t.Fatalf("error code = %q, want invalid_grant: %s", eobj.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestLoginReturnsRefreshTokenHashedAtRest(t *testing.T) {
|
|
env, _, sqlDB := newRefreshEnv(t)
|
|
access, refresh := loginRefresh(t, env.ts)
|
|
|
|
if _, err := env.auth.Verify(access); err != nil {
|
|
t.Fatalf("access token does not verify: %v", err)
|
|
}
|
|
var hash string
|
|
if err := sqlDB.QueryRowContext(context.Background(),
|
|
"SELECT token_hash FROM refresh_tokens WHERE user_id = 'admin'").Scan(&hash); err != nil {
|
|
t.Fatalf("select token_hash: %v", err)
|
|
}
|
|
if hash == refresh {
|
|
t.Fatal("refresh token stored in plaintext")
|
|
}
|
|
sum := sha256.Sum256([]byte(refresh))
|
|
if want := hex.EncodeToString(sum[:]); hash != want {
|
|
t.Fatalf("stored hash = %q, want %q", hash, want)
|
|
}
|
|
}
|
|
|
|
func TestRefreshRotatesAccessAndRefreshTokens(t *testing.T) {
|
|
env, _, _ := newRefreshEnv(t)
|
|
_, oldRefresh := loginRefresh(t, env.ts)
|
|
|
|
status, body := postRefresh(t, env.ts, oldRefresh)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("refresh status = %d, want 200: %s", status, body)
|
|
}
|
|
var data struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
Role string `json:"role"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
}
|
|
decodeData(t, body, &data)
|
|
if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" {
|
|
t.Fatalf("refresh payload = %+v, want token/expiresAt/role", data)
|
|
}
|
|
if data.RefreshToken == "" || data.RefreshToken == oldRefresh {
|
|
t.Fatalf("refresh token not rotated: %q", data.RefreshToken)
|
|
}
|
|
claims, err := env.auth.Verify(data.Token)
|
|
if err != nil {
|
|
t.Fatalf("refreshed access token does not verify: %v", err)
|
|
}
|
|
if claims.Sub != "admin" || claims.Role != auth.RoleOwner {
|
|
t.Fatalf("refreshed claims = %+v, want admin/owner", claims)
|
|
}
|
|
|
|
// 出示的 token 是一次性的:重放必须失败。
|
|
status, body = postRefresh(t, env.ts, oldRefresh)
|
|
wantInvalidGrant(t, status, body)
|
|
|
|
// 轮换后的 token 有效。
|
|
if status, body = postRefresh(t, env.ts, data.RefreshToken); status != http.StatusOK {
|
|
t.Fatalf("rotate replacement status = %d, want 200: %s", status, body)
|
|
}
|
|
}
|
|
|
|
func TestRefreshRejectsBadGrants(t *testing.T) {
|
|
env, refresh, sqlDB := newRefreshEnv(t)
|
|
ctx := context.Background()
|
|
|
|
expired := "expired-token-value"
|
|
sum := sha256.Sum256([]byte(expired))
|
|
if _, err := sqlDB.ExecContext(ctx,
|
|
`INSERT INTO refresh_tokens (id, user_id, role, token_hash, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
|
"seed-expired", "admin", "owner", hex.EncodeToString(sum[:]),
|
|
time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano)); err != nil {
|
|
t.Fatalf("seed expired token: %v", err)
|
|
}
|
|
revoked, _, err := refresh.Issue(ctx, "admin", auth.RoleOwner, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue revoked token: %v", err)
|
|
}
|
|
if ok, err := refresh.Revoke(ctx, revoked); err != nil || !ok {
|
|
t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err)
|
|
}
|
|
|
|
cases := map[string]string{
|
|
"empty": "",
|
|
"garbage": "not-a-real-token",
|
|
"expired": expired,
|
|
"revoked": revoked,
|
|
}
|
|
for name, token := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
status, body := postRefresh(t, env.ts, token)
|
|
wantInvalidGrant(t, status, body)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLogoutRevokesRefreshToken(t *testing.T) {
|
|
env, _, _ := newRefreshEnv(t)
|
|
_, refreshToken := loginRefresh(t, env.ts)
|
|
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
|
|
map[string]string{"refreshToken": refreshToken}, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body)
|
|
}
|
|
var data struct {
|
|
Revoked bool `json:"revoked"`
|
|
}
|
|
decodeData(t, body, &data)
|
|
if !data.Revoked {
|
|
t.Fatalf("logout revoked = false, want true: %s", body)
|
|
}
|
|
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
|
|
t.Fatalf("refresh after logout status = %d, want 401: %s", status, body)
|
|
}
|
|
|
|
// 登出是幂等的:第二次调用报告 revoked=false,而不是错误。
|
|
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
|
|
map[string]string{"refreshToken": refreshToken}, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("second logout status = %d, want 200: %s", resp.StatusCode, body)
|
|
}
|
|
decodeData(t, body, &data)
|
|
if data.Revoked {
|
|
t.Fatalf("second logout revoked = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestLogoutWithAccessTokenRevokesUserGrants(t *testing.T) {
|
|
env, _, _ := newRefreshEnv(t)
|
|
access, refreshToken := loginRefresh(t, env.ts)
|
|
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", nil, access)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body)
|
|
}
|
|
var data struct {
|
|
Revoked bool `json:"revoked"`
|
|
RevokedCount int64 `json:"revokedCount"`
|
|
}
|
|
decodeData(t, body, &data)
|
|
if !data.Revoked || data.RevokedCount != 1 {
|
|
t.Fatalf("logout payload = %+v, want revoked with 1 grant", data)
|
|
}
|
|
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
|
|
t.Fatalf("refresh after access-token logout status = %d, want 401: %s", status, body)
|
|
}
|
|
}
|
|
|
|
func TestRotateJWTInvalidatesRefreshTokens(t *testing.T) {
|
|
env, _, _ := newRefreshEnv(t)
|
|
_, refreshToken := loginRefresh(t, env.ts)
|
|
|
|
owner := env.token(t, auth.RoleOwner)
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/system/rotate-jwt",
|
|
map[string]string{"newSecret": "a-brand-new-jwt-secret-at-least-32-chars"}, owner)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("rotate-jwt status = %d, want 200: %s", resp.StatusCode, body)
|
|
}
|
|
var data struct {
|
|
Rotated bool `json:"rotated"`
|
|
RefreshTokensRevoked int64 `json:"refreshTokensRevoked"`
|
|
}
|
|
decodeData(t, body, &data)
|
|
if !data.Rotated || data.RefreshTokensRevoked != 1 {
|
|
t.Fatalf("rotate-jwt payload = %+v, want rotated with 1 refresh token revoked", data)
|
|
}
|
|
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
|
|
t.Fatalf("refresh after rotate-jwt status = %d, want 401: %s", status, body)
|
|
}
|
|
}
|