Files
lijiaoqiao/gateway/internal/shared/auth/auth_test.go
Your Name 3b70fe1865 P4-A: 三服务共享auth/logging能力 - 共享包边界定义/golden测试/契约测试
- gateway/internal/shared/: 新建 shared/auth 和 shared/logging 包
- shared/logging: LogEntry/Logger/NewLogger/sanitizeFields, 7个golden output测试
- shared/auth: ExtractBearerToken/HasExternalQueryKey/WriteAuthError/AuditEvent, 8个契约测试
- docs/plans/2026-04-21-shared-auth-logging-analysis.md: P4-A完整分析文档

迁移顺序: logging(第一步) -> auth基础(第二步) -> audit(第三步) -> 契约测试(第四步)
共享边界: JWT验证/token状态查询/授权策略/BruteForce保持服务特有
2026-04-21 19:00:25 +08:00

234 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sharedauth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ==================== 契约测试 ====================
// P4-A-08: 共享 auth 行为契约测试方案
// 完成标准:至少覆盖 missing bearer、invalid token、inactive token
// ====================
// TestExtractBearerToken_MissingBearer 契约测试missing bearer
func TestExtractBearerToken_MissingBearer(t *testing.T) {
testCases := []struct {
name string
authHeader string
wantToken string
wantOK bool
}{
{"empty header", "", "", false},
{"no bearer prefix", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "", false},
{"basic auth", "Basic dXNlcjpwYXNz", "", false},
{"bearer only whitespace", "Bearer ", "", false},
{"bearer lowercase", "bearer token123", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
token, ok := ExtractBearerToken(tc.authHeader)
if ok != tc.wantOK {
t.Errorf("ExtractBearerToken(%q) ok = %v, want %v", tc.authHeader, ok, tc.wantOK)
}
if token != tc.wantToken {
t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tc.authHeader, token, tc.wantToken)
}
})
}
}
// TestExtractBearerToken_ValidBearer 契约测试valid bearer
func TestExtractBearerToken_ValidBearer(t *testing.T) {
testCases := []struct {
name string
authHeader string
wantToken string
}{
{"standard bearer", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"},
{"with trimming", "Bearer tok_abc123 ", "tok_abc123"},
{"short token", "Bearer a", "a"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
token, ok := ExtractBearerToken(tc.authHeader)
if !ok {
t.Fatalf("ExtractBearerToken(%q) ok = false, want true", tc.authHeader)
}
if token != tc.wantToken {
t.Errorf("token = %q, want %q", token, tc.wantToken)
}
})
}
}
// TestHasExternalQueryKey_MissingBearer 契约测试query key 检测
func TestHasExternalQueryKey(t *testing.T) {
testCases := []struct {
name string
queryVals map[string][]string
want bool
}{
{"no sensitive keys", map[string][]string{"page": {"1"}, "limit": {"10"}}, false},
{"key parameter", map[string][]string{"key": {"tok_xxxxx"}}, true},
{"api_key parameter", map[string][]string{"api_key": {"sk_live_xxxxx"}}, true},
{"token parameter", map[string][]string{"token": {"tok_xxxxx"}}, true},
{"access_token parameter", map[string][]string{"access_token": {"tok_xxxxx"}}, true},
{"KEY uppercase", map[string][]string{"KEY": {"secret"}}, true},
{"API_KEY mixed case", map[string][]string{"Api_Key": {"key"}}, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := HasExternalQueryKey(tc.queryVals)
if got != tc.want {
t.Errorf("HasExternalQueryKey(%v) = %v, want %v", tc.queryVals, got, tc.want)
}
})
}
}
// TestWriteAuthError_MissingBearer 契约测试missing bearer 错误响应
func TestWriteAuthError_MissingBearer(t *testing.T) {
rr := httptest.NewRecorder()
WriteAuthError(rr, http.StatusUnauthorized, "req-123", CodeMissingBearer,
"Authorization header with Bearer token is required")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
var resp errorResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if resp.RequestID != "req-123" {
t.Errorf("request_id = %q, want req-123", resp.RequestID)
}
if resp.Error.Code != string(CodeMissingBearer) {
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeMissingBearer)
}
}
// TestWriteAuthError_InvalidToken 契约测试invalid token 错误响应
func TestWriteAuthError_InvalidToken(t *testing.T) {
rr := httptest.NewRecorder()
WriteAuthError(rr, http.StatusUnauthorized, "req-456", CodeInvalidToken,
"token verification failed")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
var resp errorResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if resp.Error.Code != string(CodeInvalidToken) {
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeInvalidToken)
}
}
// TestWriteAuthError_TokenInactive 契约测试inactive token 错误响应
func TestWriteAuthError_TokenInactive(t *testing.T) {
rr := httptest.NewRecorder()
WriteAuthError(rr, http.StatusUnauthorized, "req-789", CodeTokenInactive,
"token is revoked or expired")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
var resp errorResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if resp.Error.Code != string(CodeTokenInactive) {
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeTokenInactive)
}
}
// TestWriteAuthError_AuthzDenied 契约测试scope denied 错误响应
func TestWriteAuthError_AuthzDenied(t *testing.T) {
rr := httptest.NewRecorder()
WriteAuthError(rr, http.StatusForbidden, "req-abc", CodeAuthzDenied,
"required scope 'admin' is not granted")
if rr.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", rr.Code, http.StatusForbidden)
}
var resp errorResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if resp.Error.Code != string(CodeAuthzDenied) {
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeAuthzDenied)
}
}
// TestWriteAuthError_ContentType 契约测试:所有错误响应 Content-Type 为 application/json
func TestWriteAuthError_ContentType(t *testing.T) {
codes := []AuthErrorCode{
CodeMissingBearer,
CodeInvalidToken,
CodeTokenInactive,
CodeQueryKeyNotAllowed,
CodeAuthzDenied,
CodeAuthNotReady,
}
for _, code := range codes {
t.Run(string(code), func(t *testing.T) {
rr := httptest.NewRecorder()
WriteAuthError(rr, http.StatusUnauthorized, "req-test", code, "test message")
contentType := rr.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Content-Type = %q, want 'application/json'", contentType)
}
})
}
}
// TestAuditEvent_JSONSerialization 契约测试AuditEvent JSON 序列化
func TestAuditEvent_JSONSerialization(t *testing.T) {
event := AuditEvent{
EventName: "token.authn.fail",
RequestID: "req-123",
TokenID: "tok_abc",
SubjectID: "user_456",
Route: "/api/v1/supply/accounts",
ResultCode: string(CodeInvalidToken),
ClientIP: "10.0.0.1",
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var decoded AuditEvent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if decoded.EventName != event.EventName {
t.Errorf("event_name = %q, want %q", decoded.EventName, event.EventName)
}
if decoded.RequestID != event.RequestID {
t.Errorf("request_id = %q, want %q", decoded.RequestID, event.RequestID)
}
if decoded.ResultCode != event.ResultCode {
t.Errorf("result_code = %q, want %q", decoded.ResultCode, event.ResultCode)
}
}