package auth import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" ) func bearerHeader(token string) string { return "Bearer " + token } func claimsHandler(t *testing.T) http.Handler { t.Helper() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, ok := ClaimsFromContext(r.Context()) if !ok { http.Error(w, "no claims in context", http.StatusInternalServerError) return } w.Header().Set("X-Claims-Sub", claims.Sub) w.Header().Set("X-Claims-Role", string(claims.Role)) w.WriteHeader(http.StatusOK) }) } func doAuth(h http.Handler, tok string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/", nil) if tok != "" { req.Header.Set("Authorization", bearerHeader(tok)) } rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } func responseCode(t *testing.T, rr *httptest.ResponseRecorder, want int) { t.Helper() if rr.Code != want { t.Fatalf("status = %d, want %d (body: %s)", rr.Code, want, rr.Body.String()) } } func assertErrorCode(t *testing.T, rr *httptest.ResponseRecorder, want string) { t.Helper() var body struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(strings.NewReader(rr.Body.String())).Decode(&body); err != nil { t.Fatalf("decode error body: %v", err) } if body.Error.Code != want { t.Errorf("error.code = %q, want %q", body.Error.Code, want) } } func TestAuthorize_NoBearer(t *testing.T) { t.Parallel() svc := New(testConfig("")) rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), "") responseCode(t, rr, http.StatusUnauthorized) assertErrorCode(t, rr, "unauthorized") } func TestAuthorize_InvalidToken(t *testing.T) { t.Parallel() svc := New(testConfig("")) rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), "not-a-jwt") responseCode(t, rr, http.StatusUnauthorized) assertErrorCode(t, rr, "unauthorized") } func TestAuthorize_ExpiredToken(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleViewer, -time.Minute) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), tok) responseCode(t, rr, http.StatusUnauthorized) assertErrorCode(t, rr, "unauthorized") } func TestAuthorize_OwnerSatisfiesAdminAndViewer(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(svc.Authorize(RoleAdmin, RoleViewer)(claimsHandler(t)), tok) responseCode(t, rr, http.StatusOK) if rr.Header().Get("X-Claims-Sub") != "u" { t.Errorf("X-Claims-Sub = %q, want u", rr.Header().Get("X-Claims-Sub")) } if rr.Header().Get("X-Claims-Role") != "owner" { t.Errorf("X-Claims-Role = %q, want owner", rr.Header().Get("X-Claims-Role")) } } func TestAuthorize_AdminSatisfiesViewer(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleAdmin, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), tok) responseCode(t, rr, http.StatusOK) } func TestAuthorize_AdminFailsOwner(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleAdmin, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(svc.Authorize(RoleOwner)(claimsHandler(t)), tok) responseCode(t, rr, http.StatusForbidden) assertErrorCode(t, rr, "forbidden") } func TestAuthorize_ViewerFailsAdmin(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleViewer, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(svc.Authorize(RoleAdmin)(claimsHandler(t)), tok) responseCode(t, rr, http.StatusForbidden) assertErrorCode(t, rr, "forbidden") } func TestAuthorize_AnyAuthenticated(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleViewer, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(svc.Authorize()(claimsHandler(t)), tok) responseCode(t, rr, http.StatusOK) } func TestAuthorize_BearerPrefixRequired(t *testing.T) { t.Parallel() svc := New(testConfig("")) tok, err := svc.Sign("u", RoleViewer, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", tok) // no "Bearer " prefix rr := httptest.NewRecorder() svc.Authorize(RoleViewer)(claimsHandler(t)).ServeHTTP(rr, req) responseCode(t, rr, http.StatusUnauthorized) } func TestClaimsFromContext_Missing(t *testing.T) { t.Parallel() if _, ok := ClaimsFromContext(context.Background()); ok { t.Fatal("ClaimsFromContext on empty ctx should return ok=false") } } // --- X-API-Key 分支(裁决 15.4.2 / 15.4.3)----------------------------------- // discardLog 是中间件测试用的丢弃型 logger(不产出日志噪声)。 func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // keyDoor 构建「Bearer + X-API-Key」双凭据门。 func keyDoor(keys KeyTable, required ...Role) func(http.Handler) http.Handler { return WithAPIKey(keys, New(testConfig("")).Authorize(required...), discardLog(), required...) } // doAPIKey 发一个只带 `X-API-Key` 头的请求。 func doAPIKey(h http.Handler, key string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set(apiKeyHeader, key) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } // TestWithAPIKey_ValidKeyPassesOwnerGate 是 15.4.2 的核心断言:签发出去的 key // 真的能用,并且拿到 owner 级(15.4.3),Sub 是脱敏 id 而不是密钥明文。 func TestWithAPIKey_ValidKeyPassesOwnerGate(t *testing.T) { t.Parallel() keys := newTestAPIKey() key, err := keys.Generate() if err != nil { t.Fatalf("Generate error: %v", err) } rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), key) responseCode(t, rr, http.StatusOK) if got := rr.Header().Get("X-Claims-Role"); got != string(RoleOwner) { t.Errorf("X-Claims-Role = %q, want owner", got) } if got := rr.Header().Get("X-Claims-Sub"); got != apiKeySubPrefix+MaskKey(key) { t.Errorf("X-Claims-Sub = %q, want masked id %q", got, apiKeySubPrefix+MaskKey(key)) } if strings.Contains(rr.Header().Get("X-Claims-Sub"), key) { t.Error("claims Sub leaked the key material") } } // TestWithAPIKey_UnknownKeyRejected 断言未知 key 一律 401,且**即使同时带着一份 // 合法 Bearer 也不回落** —— fail-closed,坏 key 不能借道另一份凭据混过门。 func TestWithAPIKey_UnknownKeyRejected(t *testing.T) { t.Parallel() keys := newTestAPIKey() door := keyDoor(keys, RoleOwner) rr := doAPIKey(door(claimsHandler(t)), "not-a-key") responseCode(t, rr, http.StatusUnauthorized) assertErrorCode(t, rr, "unauthorized") svc := New(testConfig("")) tok, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set(apiKeyHeader, "not-a-key") req.Header.Set("Authorization", bearerHeader(tok)) rec := httptest.NewRecorder() WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)(claimsHandler(t)).ServeHTTP(rec, req) responseCode(t, rec, http.StatusUnauthorized) } // TestWithAPIKey_RevokedKeyRejected 断言吊销后的 key 立即失效(能发也能废)。 func TestWithAPIKey_RevokedKeyRejected(t *testing.T) { t.Parallel() keys := newTestAPIKey() key, err := keys.Generate() if err != nil { t.Fatalf("Generate error: %v", err) } door := keyDoor(keys, RoleOwner) responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusOK) keys.Revoke(key) responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusUnauthorized) } // TestWithAPIKey_EmptyHeaderValueRejected 断言该头出现但值为空时按无效凭据拒绝, // 而不是被当成「没带 key」悄悄回落到 Bearer。 func TestWithAPIKey_EmptyHeaderValueRejected(t *testing.T) { t.Parallel() keys := newTestAPIKey() if _, err := keys.Generate(); err != nil { t.Fatalf("Generate error: %v", err) } rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), "") responseCode(t, rr, http.StatusUnauthorized) } // TestWithAPIKey_NilTableRejectsKeyHeader 断言 key 表未注入时带 key 的请求一律 // 拒绝(表不在 = 表为空),不带 key 的请求则照旧走 Bearer。 func TestWithAPIKey_NilTableRejectsKeyHeader(t *testing.T) { t.Parallel() svc := New(testConfig("")) door := WithAPIKey(nil, svc.Authorize(RoleOwner), discardLog(), RoleOwner) responseCode(t, doAPIKey(door(claimsHandler(t)), "any-key"), http.StatusUnauthorized) tok, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } rr := doAuth(door(claimsHandler(t)), tok) responseCode(t, rr, http.StatusOK) } // TestWithAPIKey_BearerPathUnchanged 断言不带该头时行为与纯 Bearer 门逐字一致: // 角色不足仍 403、缺凭据仍 401。 func TestWithAPIKey_BearerPathUnchanged(t *testing.T) { t.Parallel() svc := New(testConfig("")) keys := newTestAPIKey() door := WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner) admin, err := svc.Sign("u", RoleAdmin, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } responseCode(t, doAuth(door(claimsHandler(t)), admin), http.StatusForbidden) responseCode(t, doAuth(door(claimsHandler(t)), ""), http.StatusUnauthorized) owner, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } responseCode(t, doAuth(door(claimsHandler(t)), owner), http.StatusOK) }