Files
Cyrene_v2/internal/auth/rbac_test.go
T
AskaEth adef36538f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00

307 lines
9.6 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 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)
}