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) } }