- 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 显式报错,不再派生回落
307 lines
9.6 KiB
Go
307 lines
9.6 KiB
Go
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)
|
||
}
|