feat: add shared backend authorization seams
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# Task: Complete remaining Go backend modules
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260813-go-remaining-modules-7d3a9e42
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260813-go-remaining-modules-7d3a9e42-go-remaining-modules
|
||||
- Worktree: /Users/brother7/Documents/AI/NianAIGC-go-remaining-7d3a9e42
|
||||
- Base commit: d0207fcebe6ea4fb3ba80dce8c012b3c2170de40
|
||||
- Owner: codex
|
||||
- Status: Planning
|
||||
|
||||
## Scope
|
||||
|
||||
- Complete every ADR-003 Go backend module that remains after the foundation,
|
||||
database-refreshed identity, current-session HTTP, and password
|
||||
login/logout slices already present at the task base.
|
||||
- Freeze language-neutral compatibility contracts before each remaining
|
||||
vertical slice and keep the TypeScript implementation as an executable
|
||||
source-of-truth consumer until cutover.
|
||||
- Implement the remaining Identity and Administration behavior; Assets and
|
||||
storage/file serving; Billing and Usage; Jobs, providers, Webhooks, and the
|
||||
embedded WorkerLoop; and the remaining public/compatibility HTTP surface.
|
||||
- Compose all migrated routes into the separately runnable Go application and
|
||||
prove route-surface coverage without moving production traffic.
|
||||
- Keep production cutover, Next Route Handler deletion, Node Worker drain,
|
||||
Docker/ACK/Ingress ownership changes, and real RDS/OSS rollout outside this
|
||||
feature task.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Follow vertical red-green TDD at stable external HTTP Interfaces and deep
|
||||
domain/Adapter seams; do not create one shallow repository Interface per
|
||||
table.
|
||||
- Preserve current same-origin paths, method/status/JSON behavior, Cookie and
|
||||
tenant authorization, owner-scoped not-found behavior, idempotency, job
|
||||
state, wallet arithmetic, storage metadata, provider, and Webhook semantics.
|
||||
- Keep PostgreSQL as the production source of relational truth and continue
|
||||
using `claim_generation_jobs` and `billing_post_wallet_entry` for
|
||||
cross-instance concurrency. Never replace them with process-local locks.
|
||||
- Keep one owner for external side effects and every write path. The Go
|
||||
implementation remains locally runnable and contract-tested but unrouted in
|
||||
production until a later explicit cutover.
|
||||
- Keep Alibaba Cloud OSS behind an object-storage Adapter and retain an
|
||||
explicit local-development Adapter; do not claim horizontal production
|
||||
safety until real OSS and RDS/TLS checks pass.
|
||||
- Preserve the currently deployed ACK-001 Web/HTTP-polling-Worker topology and
|
||||
all production Secrets/manifests during this implementation task.
|
||||
- Work only in the owned worktree and task record; canonical project memory is
|
||||
reserved for a serialized Integration Gate.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Not completed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Not run.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Validate the complete backend against migrated non-production RDS with the
|
||||
real application role and verified-CA TLS before production cutover.
|
||||
- Validate OSS compatibility, provider credentials, external Webhooks, Worker
|
||||
drain/recovery, and rollout/rollback against production-like infrastructure.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
145
backend/internal/httpapi/platform_auth.go
Normal file
145
backend/internal/httpapi/platform_auth.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
)
|
||||
|
||||
// PlatformRequirement is the route-level authorization policy evaluated after
|
||||
// Identity has refreshed all account and organization claims from persistence.
|
||||
type PlatformRequirement string
|
||||
|
||||
const (
|
||||
PlatformApp PlatformRequirement = "app"
|
||||
PlatformAdmin PlatformRequirement = "admin"
|
||||
PlatformSuperAdmin PlatformRequirement = "super_admin"
|
||||
)
|
||||
|
||||
// PlatformAuthErrorKind is deliberately transport-oriented. Domain handlers
|
||||
// can map it to the current stable status without learning Cookie/parser detail.
|
||||
type PlatformAuthErrorKind string
|
||||
|
||||
const (
|
||||
PlatformUnauthenticated PlatformAuthErrorKind = "unauthenticated"
|
||||
PlatformForbidden PlatformAuthErrorKind = "forbidden"
|
||||
PlatformConfigurationError PlatformAuthErrorKind = "configuration_error"
|
||||
)
|
||||
|
||||
// PlatformAuthError represents only expected authentication and RBAC denials.
|
||||
// Resolver/database failures remain ordinary errors and must become generic 500s.
|
||||
type PlatformAuthError struct {
|
||||
Kind PlatformAuthErrorKind
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (err *PlatformAuthError) Error() string {
|
||||
if err.Message != "" {
|
||||
return err.Message
|
||||
}
|
||||
return string(err.Kind)
|
||||
}
|
||||
|
||||
type PlatformAuthorizer struct {
|
||||
state AuthState
|
||||
resolver SessionResolver
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewPlatformAuthorizer creates the single HTTP-side platform authentication
|
||||
// seam shared by protected route Modules.
|
||||
func NewPlatformAuthorizer(state AuthState, resolver SessionResolver) (*PlatformAuthorizer, error) {
|
||||
if state.Configured && resolver == nil {
|
||||
return nil, fmt.Errorf("platform authorization: configured authentication requires a session resolver")
|
||||
}
|
||||
return &PlatformAuthorizer{state: state, resolver: resolver, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Authorize returns either a database-refreshed platform Session, the exact
|
||||
// local-development fallback when authentication is optional, an expected
|
||||
// typed denial, or an unclassified infrastructure error.
|
||||
func (authorizer *PlatformAuthorizer) Authorize(r *http.Request, requirement PlatformRequirement) (identity.Session, error) {
|
||||
if authorizer == nil {
|
||||
return identity.Session{}, fmt.Errorf("platform authorizer is not configured")
|
||||
}
|
||||
if !validPlatformRequirement(requirement) {
|
||||
return identity.Session{}, fmt.Errorf("unsupported platform authorization requirement %q", requirement)
|
||||
}
|
||||
|
||||
if authorizer.state.Configured {
|
||||
cookieValue, found := readSessionCookie(r)
|
||||
if found && len(cookieValue) <= identity.CookieMaxValueLength {
|
||||
session, err := authorizer.resolver.Resolve(r.Context(), cookieValue)
|
||||
if err == nil {
|
||||
return authorizePlatformRole(session, requirement)
|
||||
}
|
||||
if !errors.Is(err, identity.ErrUnauthenticated) {
|
||||
return identity.Session{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authorizer.state.Required {
|
||||
return authorizePlatformRole(authorizer.localSession(), requirement)
|
||||
}
|
||||
if !authorizer.state.Configured {
|
||||
return identity.Session{}, &PlatformAuthError{
|
||||
Kind: PlatformConfigurationError, Status: http.StatusServiceUnavailable,
|
||||
Message: "认证配置不完整。",
|
||||
}
|
||||
}
|
||||
return identity.Session{}, &PlatformAuthError{
|
||||
Kind: PlatformUnauthenticated, Status: http.StatusUnauthorized,
|
||||
Message: "请先登录。",
|
||||
}
|
||||
}
|
||||
|
||||
func authorizePlatformRole(session identity.Session, requirement PlatformRequirement) (identity.Session, error) {
|
||||
allowed := requirement == PlatformApp
|
||||
if requirement == PlatformAdmin {
|
||||
allowed = session.AuthMode == identity.AuthModeAdmin &&
|
||||
(session.User.Role == "organization_admin" || session.User.Role == "super_admin")
|
||||
}
|
||||
if requirement == PlatformSuperAdmin {
|
||||
allowed = session.User.Role == "super_admin"
|
||||
}
|
||||
if allowed {
|
||||
return session, nil
|
||||
}
|
||||
return identity.Session{}, &PlatformAuthError{
|
||||
Kind: PlatformForbidden, Status: http.StatusForbidden,
|
||||
Message: "需要管理员权限。",
|
||||
}
|
||||
}
|
||||
|
||||
func validPlatformRequirement(requirement PlatformRequirement) bool {
|
||||
return requirement == PlatformApp || requirement == PlatformAdmin || requirement == PlatformSuperAdmin
|
||||
}
|
||||
|
||||
func (authorizer *PlatformAuthorizer) localSession() identity.Session {
|
||||
now := authorizer.now()
|
||||
return identity.Session{
|
||||
Version: 1,
|
||||
AuthMode: identity.AuthModeAdmin,
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(24 * time.Hour).Unix(),
|
||||
User: identity.User{
|
||||
ID: "demo-merchant",
|
||||
Subject: "demo-merchant",
|
||||
Username: "13800000000",
|
||||
Phone: "13800000000",
|
||||
DisplayName: "智念用户",
|
||||
ClientID: "local-dev",
|
||||
OrganizationID: "org-demo",
|
||||
OrganizationName: "演示组织",
|
||||
Role: "super_admin",
|
||||
Status: "active",
|
||||
Authorities: []string{"zhinian_admin"},
|
||||
Scope: []string{},
|
||||
},
|
||||
}
|
||||
}
|
||||
165
backend/internal/httpapi/platform_auth_test.go
Normal file
165
backend/internal/httpapi/platform_auth_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
)
|
||||
|
||||
func TestPlatformAuthorizerMatchesSharedContract(t *testing.T) {
|
||||
contract := loadPlatformHTTPAuthContract(t)
|
||||
for _, contractCase := range contract.Cases {
|
||||
t.Run(contractCase.Name, func(t *testing.T) {
|
||||
resolver := &platformSessionResolverStub{outcome: contractCase.ResolverOutcome}
|
||||
if contractCase.Session != nil {
|
||||
resolver.session = fixturePlatformSession(*contractCase.Session)
|
||||
}
|
||||
authorizer, err := NewPlatformAuthorizer(AuthState(contractCase.State), resolver)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlatformAuthorizer: %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
if contractCase.ResolverOutcome != "not_called" {
|
||||
request.AddCookie(&http.Cookie{Name: contract.CookieName, Value: "signed-cookie"})
|
||||
}
|
||||
|
||||
session, err := authorizer.Authorize(request, PlatformRequirement(contractCase.Requirement))
|
||||
assertPlatformAuthOutcome(t, contractCase, session, err)
|
||||
wantCalls := 0
|
||||
if contractCase.ResolverOutcome != "not_called" {
|
||||
wantCalls = 1
|
||||
}
|
||||
if resolver.calls != wantCalls {
|
||||
t.Fatalf("resolver calls = %d, want %d", resolver.calls, wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformAuthorizerUsesSharedChunkReader(t *testing.T) {
|
||||
resolver := &platformSessionResolverStub{outcome: "authenticated", session: fixturePlatformSession(platformHTTPFixtureSession{AuthMode: "user", Role: "user"})}
|
||||
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, resolver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "first"})
|
||||
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "last"})
|
||||
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName + ".1", Value: "tail"})
|
||||
|
||||
if _, err := authorizer.Authorize(request, PlatformApp); err != nil {
|
||||
t.Fatalf("Authorize: %v", err)
|
||||
}
|
||||
if resolver.value != "lasttail" {
|
||||
t.Fatalf("resolver value = %q, want lasttail", resolver.value)
|
||||
}
|
||||
}
|
||||
|
||||
type platformSessionResolverStub struct {
|
||||
outcome string
|
||||
session identity.Session
|
||||
calls int
|
||||
value string
|
||||
}
|
||||
|
||||
func (stub *platformSessionResolverStub) Resolve(_ context.Context, value string) (identity.Session, error) {
|
||||
stub.calls++
|
||||
stub.value = value
|
||||
switch stub.outcome {
|
||||
case "authenticated":
|
||||
return stub.session, nil
|
||||
case "unauthenticated":
|
||||
return identity.Session{}, identity.ErrUnauthenticated
|
||||
case "infrastructure_error":
|
||||
return identity.Session{}, errors.New("database secret must not leak")
|
||||
default:
|
||||
return identity.Session{}, errors.New("unexpected resolver call")
|
||||
}
|
||||
}
|
||||
|
||||
type platformHTTPAuthContract struct {
|
||||
Version int `json:"version"`
|
||||
CookieName string `json:"cookieName"`
|
||||
Cases []platformHTTPAuthCase `json:"cases"`
|
||||
}
|
||||
|
||||
type platformHTTPAuthCase struct {
|
||||
Name string `json:"name"`
|
||||
State AuthState `json:"state"`
|
||||
Requirement string `json:"requirement"`
|
||||
ResolverOutcome string `json:"resolverOutcome"`
|
||||
Session *platformHTTPFixtureSession `json:"session"`
|
||||
Expected struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Status int `json:"status"`
|
||||
Role string `json:"role"`
|
||||
AuthMode string `json:"authMode"`
|
||||
} `json:"expected"`
|
||||
}
|
||||
|
||||
type platformHTTPFixtureSession struct {
|
||||
AuthMode string `json:"authMode"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func loadPlatformHTTPAuthContract(t *testing.T) platformHTTPAuthContract {
|
||||
t.Helper()
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate platform_auth_test.go")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(filename), "..", "..", "..", "contracts", "auth", "platform-http-auth-v1.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contract platformHTTPAuthContract
|
||||
if err := json.Unmarshal(raw, &contract); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contract.Version != 1 || contract.CookieName != identity.SessionCookieName {
|
||||
t.Fatalf("invalid contract header: %+v", contract)
|
||||
}
|
||||
return contract
|
||||
}
|
||||
|
||||
func fixturePlatformSession(input platformHTTPFixtureSession) identity.Session {
|
||||
return identity.Session{
|
||||
Version: 1,
|
||||
AuthMode: identity.AuthMode(input.AuthMode),
|
||||
User: identity.User{
|
||||
ID: "fixture-user", Subject: "fixture-user", DisplayName: "Fixture User",
|
||||
ClientID: "platform", Role: input.Role, Status: "active",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertPlatformAuthOutcome(t *testing.T, contractCase platformHTTPAuthCase, session identity.Session, err error) {
|
||||
t.Helper()
|
||||
switch contractCase.Expected.Outcome {
|
||||
case "authenticated":
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize error = %v", err)
|
||||
}
|
||||
if session.User.Role != contractCase.Expected.Role || string(session.AuthMode) != contractCase.Expected.AuthMode {
|
||||
t.Fatalf("session role/mode = %q/%q, want %q/%q", session.User.Role, session.AuthMode, contractCase.Expected.Role, contractCase.Expected.AuthMode)
|
||||
}
|
||||
case "infrastructure_error":
|
||||
var authErr *PlatformAuthError
|
||||
if err == nil || errors.As(err, &authErr) {
|
||||
t.Fatalf("error = %v, want non-auth infrastructure error", err)
|
||||
}
|
||||
default:
|
||||
var authErr *PlatformAuthError
|
||||
if !errors.As(err, &authErr) || authErr.Status != contractCase.Expected.Status || string(authErr.Kind) != contractCase.Expected.Outcome {
|
||||
t.Fatalf("error = %#v, want %s/%d", err, contractCase.Expected.Outcome, contractCase.Expected.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
162
backend/internal/publicapi/auth.go
Normal file
162
backend/internal/publicapi/auth.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Package publicapi authenticates public API clients and internal workers.
|
||||
// Runtime configuration is parsed once and injected into an Authenticator;
|
||||
// this package deliberately does not read process environment variables.
|
||||
package publicapi
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ownerPartLimit = 96
|
||||
|
||||
type PublicClient struct {
|
||||
ID string
|
||||
Key string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
APIKeys string
|
||||
InternalWorkerToken string
|
||||
Production bool
|
||||
}
|
||||
|
||||
type AuthError struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
clients []PublicClient
|
||||
internalWorkerToken string
|
||||
production bool
|
||||
}
|
||||
|
||||
func NewAuthenticator(config Config) *Authenticator {
|
||||
return &Authenticator{
|
||||
clients: ParseClients(config.APIKeys),
|
||||
internalWorkerToken: strings.TrimSpace(config.InternalWorkerToken),
|
||||
production: config.Production,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseClients(configured string) []PublicClient {
|
||||
entries := strings.FieldsFunc(configured, func(character rune) bool {
|
||||
return character == ',' || character == '\n'
|
||||
})
|
||||
clients := make([]PublicClient, 0, len(entries))
|
||||
for _, rawEntry := range entries {
|
||||
entry := strings.TrimSpace(rawEntry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
id := "default"
|
||||
key := entry
|
||||
if separator := strings.IndexByte(entry, ':'); separator >= 0 {
|
||||
id = strings.TrimSpace(entry[:separator])
|
||||
key = strings.TrimSpace(entry[separator+1:])
|
||||
}
|
||||
if id == "" || key == "" {
|
||||
continue
|
||||
}
|
||||
clients = append(clients, PublicClient{ID: id, Key: key})
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(request *http.Request) (PublicClient, string, error) {
|
||||
presented := publicCredential(request)
|
||||
if presented == "" {
|
||||
return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Missing API key."}
|
||||
}
|
||||
for _, client := range a.clients {
|
||||
if secureEqual(client.Key, presented) {
|
||||
return client, OwnerID(client.ID), nil
|
||||
}
|
||||
}
|
||||
return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Invalid API key."}
|
||||
}
|
||||
|
||||
func (a *Authenticator) AssertInternalWorker(request *http.Request) error {
|
||||
if a.internalWorkerToken == "" && !a.production {
|
||||
return nil
|
||||
}
|
||||
if a.internalWorkerToken == "" {
|
||||
return &AuthError{Status: http.StatusInternalServerError, Message: "Worker token is not configured."}
|
||||
}
|
||||
|
||||
presented := request.Header.Get("x-zhinian-worker-token")
|
||||
if presented == "" {
|
||||
presented = bearerToken(request)
|
||||
}
|
||||
if presented == "" || !secureEqual(a.internalWorkerToken, presented) {
|
||||
return &AuthError{Status: http.StatusUnauthorized, Message: "Invalid worker token."}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func OwnerID(id string) string {
|
||||
part := sanitizeOwnerPart(id)
|
||||
if part == "" {
|
||||
part = "unknown"
|
||||
}
|
||||
return "api:" + part
|
||||
}
|
||||
|
||||
func publicCredential(request *http.Request) string {
|
||||
if token := bearerToken(request); token != "" {
|
||||
return token
|
||||
}
|
||||
return request.Header.Get("x-zhinian-api-key")
|
||||
}
|
||||
|
||||
func bearerToken(request *http.Request) string {
|
||||
authorization := request.Header.Get("authorization")
|
||||
separator := strings.IndexAny(authorization, " \t\r\n\v\f")
|
||||
if separator <= 0 || !strings.EqualFold(authorization[:separator], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimLeft(authorization[separator:], " \t\r\n\v\f") == authorization[separator:] {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(authorization[separator:])
|
||||
}
|
||||
|
||||
func secureEqual(expected, presented string) bool {
|
||||
if len(expected) != len(presented) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1
|
||||
}
|
||||
|
||||
func sanitizeOwnerPart(value string) string {
|
||||
part := make([]byte, 0, min(len(value), ownerPartLimit))
|
||||
invalidRun := false
|
||||
for _, character := range value {
|
||||
if isOwnerCharacter(character) {
|
||||
invalidRun = false
|
||||
if len(part) < ownerPartLimit {
|
||||
part = append(part, byte(character))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !invalidRun && len(part) < ownerPartLimit {
|
||||
part = append(part, '_')
|
||||
}
|
||||
invalidRun = true
|
||||
}
|
||||
return string(part)
|
||||
}
|
||||
|
||||
func isOwnerCharacter(character rune) bool {
|
||||
return character >= 'A' && character <= 'Z' ||
|
||||
character >= 'a' && character <= 'z' ||
|
||||
character >= '0' && character <= '9' ||
|
||||
strings.ContainsRune("_.:@-", character)
|
||||
}
|
||||
171
backend/internal/publicapi/auth_test.go
Normal file
171
backend/internal/publicapi/auth_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package publicapi_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi"
|
||||
)
|
||||
|
||||
type authErrorFixture struct {
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type authFixture struct {
|
||||
Version int `json:"version"`
|
||||
APIKeys string `json:"apiKeys"`
|
||||
Clients []struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
} `json:"clients"`
|
||||
AuthenticationCases []struct {
|
||||
Name string `json:"name"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Expected *struct {
|
||||
Client struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
} `json:"client"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"expected"`
|
||||
Error *authErrorFixture `json:"error"`
|
||||
} `json:"authenticationCases"`
|
||||
OwnerCases []struct {
|
||||
ID string `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
MaxPartLength int `json:"maxPartLength"`
|
||||
} `json:"ownerCases"`
|
||||
WorkerCases []struct {
|
||||
Name string `json:"name"`
|
||||
Production bool `json:"production"`
|
||||
ConfiguredToken string `json:"configuredToken"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Allowed bool `json:"allowed"`
|
||||
Error *authErrorFixture `json:"error"`
|
||||
} `json:"workerCases"`
|
||||
}
|
||||
|
||||
func TestParseClientsMatchesSharedContract(t *testing.T) {
|
||||
fixture := loadAuthFixture(t)
|
||||
clients := publicapi.ParseClients(fixture.APIKeys)
|
||||
if len(clients) != len(fixture.Clients) {
|
||||
t.Fatalf("len(ParseClients()) = %d, want %d", len(clients), len(fixture.Clients))
|
||||
}
|
||||
for index, want := range fixture.Clients {
|
||||
if clients[index] != (publicapi.PublicClient{ID: want.ID, Key: want.Key}) {
|
||||
t.Fatalf("client[%d] = %+v, want %+v", index, clients[index], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateMatchesSharedContract(t *testing.T) {
|
||||
fixture := loadAuthFixture(t)
|
||||
authenticator := publicapi.NewAuthenticator(publicapi.Config{APIKeys: fixture.APIKeys})
|
||||
for _, testCase := range fixture.AuthenticationCases {
|
||||
t.Run(testCase.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/v1/generations", nil)
|
||||
for name, value := range testCase.Headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
client, owner, err := authenticator.Authenticate(request)
|
||||
if testCase.Error != nil {
|
||||
assertAuthError(t, err, *testCase.Error)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate() error = %v", err)
|
||||
}
|
||||
wantClient := publicapi.PublicClient{ID: testCase.Expected.Client.ID, Key: testCase.Expected.Client.Key}
|
||||
if client != wantClient || owner != testCase.Expected.Owner {
|
||||
t.Fatalf("Authenticate() = (%+v, %q), want (%+v, %q)", client, owner, wantClient, testCase.Expected.Owner)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerIDMatchesSharedContract(t *testing.T) {
|
||||
fixture := loadAuthFixture(t)
|
||||
for _, testCase := range fixture.OwnerCases {
|
||||
t.Run(testCase.ID, func(t *testing.T) {
|
||||
owner := publicapi.OwnerID(testCase.ID)
|
||||
if owner != testCase.Owner {
|
||||
t.Fatalf("OwnerID(%q) = %q, want %q", testCase.ID, owner, testCase.Owner)
|
||||
}
|
||||
if testCase.MaxPartLength > 0 && len(owner)-len("api:") != testCase.MaxPartLength {
|
||||
t.Fatalf("owner part length = %d, want %d", len(owner)-len("api:"), testCase.MaxPartLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertInternalWorkerMatchesSharedContract(t *testing.T) {
|
||||
fixture := loadAuthFixture(t)
|
||||
for _, testCase := range fixture.WorkerCases {
|
||||
t.Run(testCase.Name, func(t *testing.T) {
|
||||
authenticator := publicapi.NewAuthenticator(publicapi.Config{
|
||||
InternalWorkerToken: testCase.ConfiguredToken,
|
||||
Production: testCase.Production,
|
||||
})
|
||||
request := httptest.NewRequest("POST", "/api/internal/worker/tick", nil)
|
||||
for name, value := range testCase.Headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
err := authenticator.AssertInternalWorker(request)
|
||||
if testCase.Error != nil {
|
||||
assertAuthError(t, err, *testCase.Error)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("AssertInternalWorker() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorUsesOnlyInjectedConfig(t *testing.T) {
|
||||
t.Setenv("ZHINIAN_API_KEYS", "environment:must-not-be-read")
|
||||
t.Setenv("ZHINIAN_INTERNAL_WORKER_TOKEN", "environment-worker-token")
|
||||
t.Setenv("NODE_ENV", "production")
|
||||
|
||||
authenticator := publicapi.NewAuthenticator(publicapi.Config{})
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.Header.Set("authorization", "Bearer must-not-be-read")
|
||||
_, _, err := authenticator.Authenticate(request)
|
||||
assertAuthError(t, err, authErrorFixture{Status: 401, Message: "Invalid API key."})
|
||||
if err := authenticator.AssertInternalWorker(httptest.NewRequest("POST", "/", nil)); err != nil {
|
||||
t.Fatalf("development bypass with injected zero config error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuthError(t *testing.T, err error, want authErrorFixture) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("error = nil, want typed authentication error")
|
||||
}
|
||||
authError, ok := err.(*publicapi.AuthError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *publicapi.AuthError", err)
|
||||
}
|
||||
if authError.Status != want.Status || authError.Message != want.Message || authError.Error() != want.Message {
|
||||
t.Fatalf("error = %+v, want status=%d message=%q", authError, want.Status, want.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func loadAuthFixture(t *testing.T) authFixture {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../../contracts/auth/public-api-auth-v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read public API auth fixture: %v", err)
|
||||
}
|
||||
var fixture authFixture
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
t.Fatalf("decode public API auth fixture: %v", err)
|
||||
}
|
||||
if fixture.Version != 1 {
|
||||
t.Fatalf("fixture version = %d, want 1", fixture.Version)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
89
contracts/auth/platform-http-auth-v1.json
Normal file
89
contracts/auth/platform-http-auth-v1.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"version": 1,
|
||||
"cookieName": "zhinian_session",
|
||||
"cases": [
|
||||
{
|
||||
"name": "optional unconfigured application access uses the local administrator",
|
||||
"state": { "required": false, "configured": false },
|
||||
"requirement": "app",
|
||||
"resolverOutcome": "not_called",
|
||||
"expected": { "outcome": "authenticated", "status": 200, "role": "super_admin", "authMode": "admin" }
|
||||
},
|
||||
{
|
||||
"name": "required unconfigured authentication fails with service unavailable",
|
||||
"state": { "required": true, "configured": false },
|
||||
"requirement": "app",
|
||||
"resolverOutcome": "not_called",
|
||||
"expected": { "outcome": "configuration_error", "status": 503 }
|
||||
},
|
||||
{
|
||||
"name": "required configured request without a cookie is unauthenticated",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "app",
|
||||
"resolverOutcome": "not_called",
|
||||
"expected": { "outcome": "unauthenticated", "status": 401 }
|
||||
},
|
||||
{
|
||||
"name": "an invalid session falls back locally only when authentication is optional",
|
||||
"state": { "required": false, "configured": true },
|
||||
"requirement": "super_admin",
|
||||
"resolverOutcome": "unauthenticated",
|
||||
"expected": { "outcome": "authenticated", "status": 200, "role": "super_admin", "authMode": "admin" }
|
||||
},
|
||||
{
|
||||
"name": "database refreshed user is allowed application access",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "app",
|
||||
"resolverOutcome": "authenticated",
|
||||
"session": { "authMode": "user", "role": "user" },
|
||||
"expected": { "outcome": "authenticated", "status": 200, "role": "user", "authMode": "user" }
|
||||
},
|
||||
{
|
||||
"name": "ordinary user cannot access administration",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "admin",
|
||||
"resolverOutcome": "authenticated",
|
||||
"session": { "authMode": "user", "role": "user" },
|
||||
"expected": { "outcome": "forbidden", "status": 403 }
|
||||
},
|
||||
{
|
||||
"name": "organization administrator in admin mode can access administration",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "admin",
|
||||
"resolverOutcome": "authenticated",
|
||||
"session": { "authMode": "admin", "role": "organization_admin" },
|
||||
"expected": { "outcome": "authenticated", "status": 200, "role": "organization_admin", "authMode": "admin" }
|
||||
},
|
||||
{
|
||||
"name": "admin role without admin login mode cannot access administration",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "admin",
|
||||
"resolverOutcome": "authenticated",
|
||||
"session": { "authMode": "user", "role": "organization_admin" },
|
||||
"expected": { "outcome": "forbidden", "status": 403 }
|
||||
},
|
||||
{
|
||||
"name": "organization administrator cannot access super administration",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "super_admin",
|
||||
"resolverOutcome": "authenticated",
|
||||
"session": { "authMode": "admin", "role": "organization_admin" },
|
||||
"expected": { "outcome": "forbidden", "status": 403 }
|
||||
},
|
||||
{
|
||||
"name": "super administrator can access super administration",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "super_admin",
|
||||
"resolverOutcome": "authenticated",
|
||||
"session": { "authMode": "admin", "role": "super_admin" },
|
||||
"expected": { "outcome": "authenticated", "status": 200, "role": "super_admin", "authMode": "admin" }
|
||||
},
|
||||
{
|
||||
"name": "resolver infrastructure failure is not disguised as authentication denial",
|
||||
"state": { "required": true, "configured": true },
|
||||
"requirement": "app",
|
||||
"resolverOutcome": "infrastructure_error",
|
||||
"expected": { "outcome": "infrastructure_error", "status": 500 }
|
||||
}
|
||||
]
|
||||
}
|
||||
91
contracts/auth/public-api-auth-v1.json
Normal file
91
contracts/auth/public-api-auth-v1.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"version": 1,
|
||||
"apiKeys": " alpha : alpha-secret ,\n bare-secret\n, invalid-only-id: , :invalid-only-key, beta: beta-secret ",
|
||||
"clients": [
|
||||
{ "id": "alpha", "key": "alpha-secret" },
|
||||
{ "id": "default", "key": "bare-secret" },
|
||||
{ "id": "beta", "key": "beta-secret" }
|
||||
],
|
||||
"authenticationCases": [
|
||||
{
|
||||
"name": "case insensitive bearer authenticates",
|
||||
"headers": { "authorization": "bEaReR alpha-secret " },
|
||||
"expected": { "client": { "id": "alpha", "key": "alpha-secret" }, "owner": "api:alpha" }
|
||||
},
|
||||
{
|
||||
"name": "legacy api key header authenticates",
|
||||
"headers": { "x-zhinian-api-key": "bare-secret" },
|
||||
"expected": { "client": { "id": "default", "key": "bare-secret" }, "owner": "api:default" }
|
||||
},
|
||||
{
|
||||
"name": "bearer takes precedence over legacy header",
|
||||
"headers": { "authorization": "Bearer beta-secret", "x-zhinian-api-key": "alpha-secret" },
|
||||
"expected": { "client": { "id": "beta", "key": "beta-secret" }, "owner": "api:beta" }
|
||||
},
|
||||
{
|
||||
"name": "invalid bearer does not fall back to legacy header",
|
||||
"headers": { "authorization": "Bearer wrong", "x-zhinian-api-key": "alpha-secret" },
|
||||
"error": { "status": 401, "message": "Invalid API key." }
|
||||
},
|
||||
{
|
||||
"name": "missing credentials are typed",
|
||||
"headers": {},
|
||||
"error": { "status": 401, "message": "Missing API key." }
|
||||
},
|
||||
{
|
||||
"name": "different length credential is invalid",
|
||||
"headers": { "authorization": "Bearer x" },
|
||||
"error": { "status": 401, "message": "Invalid API key." }
|
||||
}
|
||||
],
|
||||
"ownerCases": [
|
||||
{ "id": "tenant / 中文!?", "owner": "api:tenant_" },
|
||||
{ "id": "", "owner": "api:unknown" },
|
||||
{ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "owner": "api:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "maxPartLength": 96 },
|
||||
{ "id": "safe.ID:@-42", "owner": "api:safe.ID:@-42" }
|
||||
],
|
||||
"workerCases": [
|
||||
{
|
||||
"name": "development permits an unconfigured token",
|
||||
"production": false,
|
||||
"configuredToken": " ",
|
||||
"headers": {},
|
||||
"allowed": true
|
||||
},
|
||||
{
|
||||
"name": "production rejects an unconfigured token",
|
||||
"production": true,
|
||||
"configuredToken": " ",
|
||||
"headers": {},
|
||||
"error": { "status": 500, "message": "Worker token is not configured." }
|
||||
},
|
||||
{
|
||||
"name": "worker header authenticates",
|
||||
"production": true,
|
||||
"configuredToken": " worker-secret ",
|
||||
"headers": { "x-zhinian-worker-token": "worker-secret" },
|
||||
"allowed": true
|
||||
},
|
||||
{
|
||||
"name": "worker header takes precedence over bearer",
|
||||
"production": true,
|
||||
"configuredToken": "worker-secret",
|
||||
"headers": { "x-zhinian-worker-token": "wrong", "authorization": "Bearer worker-secret" },
|
||||
"error": { "status": 401, "message": "Invalid worker token." }
|
||||
},
|
||||
{
|
||||
"name": "worker bearer is case insensitive",
|
||||
"production": true,
|
||||
"configuredToken": "worker-secret",
|
||||
"headers": { "authorization": "BEARER worker-secret" },
|
||||
"allowed": true
|
||||
},
|
||||
{
|
||||
"name": "invalid worker token is typed",
|
||||
"production": false,
|
||||
"configuredToken": "worker-secret",
|
||||
"headers": { "x-zhinian-worker-token": "x" },
|
||||
"error": { "status": 401, "message": "Invalid worker token." }
|
||||
}
|
||||
]
|
||||
}
|
||||
82
tests/auth-platform-http-auth-contract.test.ts
Normal file
82
tests/auth-platform-http-auth-contract.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions";
|
||||
import type { AuthSession } from "@/lib/auth/session";
|
||||
|
||||
type FixtureCase = {
|
||||
name: string;
|
||||
state: { required: boolean; configured: boolean };
|
||||
requirement: "app" | "admin" | "super_admin";
|
||||
resolverOutcome: "not_called" | "unauthenticated" | "authenticated" | "infrastructure_error";
|
||||
session?: { authMode: "user" | "admin"; role: "user" | "organization_admin" | "super_admin" };
|
||||
expected: { outcome: string; status: number; role?: string; authMode?: string };
|
||||
};
|
||||
|
||||
type Fixture = { version: 1; cookieName: string; cases: FixtureCase[] };
|
||||
|
||||
const fixtureUrl = new URL("../contracts/auth/platform-http-auth-v1.json", import.meta.url);
|
||||
|
||||
describe("platform HTTP authorization v1 contract", () => {
|
||||
it("freezes the current configuration fallback and error categories", async () => {
|
||||
const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as Fixture;
|
||||
expect(fixture.version).toBe(1);
|
||||
expect(fixture.cookieName).toBe("zhinian_session");
|
||||
|
||||
for (const contractCase of fixture.cases) {
|
||||
expect(evaluateConfiguration(contractCase), contractCase.name).toEqual(contractCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the current role and auth-mode permission helpers", async () => {
|
||||
const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as Fixture;
|
||||
for (const contractCase of fixture.cases.filter((item) => item.session)) {
|
||||
const session = fixtureSession(contractCase.session!);
|
||||
const allowed = contractCase.requirement === "app"
|
||||
? true
|
||||
: contractCase.requirement === "admin"
|
||||
? hasAdminSessionAccess(session)
|
||||
: hasSuperAdminAccess(session.user);
|
||||
expect(allowed, contractCase.name).toBe(contractCase.expected.outcome === "authenticated");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function evaluateConfiguration(contractCase: FixtureCase): FixtureCase["expected"] {
|
||||
if (contractCase.resolverOutcome === "infrastructure_error") {
|
||||
return { outcome: "infrastructure_error", status: 500 };
|
||||
}
|
||||
if (contractCase.resolverOutcome === "authenticated") {
|
||||
const session = fixtureSession(contractCase.session!);
|
||||
const allowed = contractCase.requirement === "app"
|
||||
|| (contractCase.requirement === "admin" && hasAdminSessionAccess(session))
|
||||
|| (contractCase.requirement === "super_admin" && hasSuperAdminAccess(session.user));
|
||||
return allowed
|
||||
? { outcome: "authenticated", status: 200, role: session.user.role, authMode: session.authMode }
|
||||
: { outcome: "forbidden", status: 403 };
|
||||
}
|
||||
if (!contractCase.state.required) {
|
||||
return { outcome: "authenticated", status: 200, role: "super_admin", authMode: "admin" };
|
||||
}
|
||||
if (!contractCase.state.configured) return { outcome: "configuration_error", status: 503 };
|
||||
return { outcome: "unauthenticated", status: 401 };
|
||||
}
|
||||
|
||||
function fixtureSession(input: NonNullable<FixtureCase["session"]>): AuthSession {
|
||||
return {
|
||||
version: 1,
|
||||
authMode: input.authMode,
|
||||
issuedAt: 100,
|
||||
expiresAt: 200,
|
||||
user: {
|
||||
id: "fixture-user",
|
||||
subject: "fixture-user",
|
||||
displayName: "Fixture User",
|
||||
clientId: "platform",
|
||||
role: input.role,
|
||||
authorities: [],
|
||||
scope: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
112
tests/public-api-auth-contract.test.ts
Normal file
112
tests/public-api-auth-contract.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PublicApiAuthError,
|
||||
assertInternalWorkerToken,
|
||||
authenticatePublicApiRequest,
|
||||
getPublicApiClients,
|
||||
publicApiOwnerId,
|
||||
type PublicApiClient
|
||||
} from "@/lib/server/public-api-auth";
|
||||
|
||||
type AuthError = { status: number; message: string };
|
||||
type HeaderMap = Record<string, string>;
|
||||
type PublicApiAuthFixture = {
|
||||
version: 1;
|
||||
apiKeys: string;
|
||||
clients: PublicApiClient[];
|
||||
authenticationCases: Array<{
|
||||
name: string;
|
||||
headers: HeaderMap;
|
||||
expected?: { client: PublicApiClient; owner: string };
|
||||
error?: AuthError;
|
||||
}>;
|
||||
ownerCases: Array<{ id: string; owner: string; maxPartLength?: number }>;
|
||||
workerCases: Array<{
|
||||
name: string;
|
||||
production: boolean;
|
||||
configuredToken: string;
|
||||
headers: HeaderMap;
|
||||
allowed?: boolean;
|
||||
error?: AuthError;
|
||||
}>;
|
||||
};
|
||||
|
||||
const fixtureUrl = new URL("../contracts/auth/public-api-auth-v1.json", import.meta.url);
|
||||
const environmentKeys = ["ZHINIAN_API_KEYS", "ZHINIAN_INTERNAL_WORKER_TOKEN", "NODE_ENV"] as const;
|
||||
const originalEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of environmentKeys) {
|
||||
const value = originalEnvironment.get(key);
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||
else Reflect.set(process.env, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFixture(): Promise<PublicApiAuthFixture> {
|
||||
return JSON.parse(await readFile(fixtureUrl, "utf8")) as PublicApiAuthFixture;
|
||||
}
|
||||
|
||||
function request(headers: HeaderMap): Request {
|
||||
return new Request("https://api.example.test/api/v1/generations", { headers });
|
||||
}
|
||||
|
||||
function expectTypedError(action: () => unknown, expected: AuthError) {
|
||||
try {
|
||||
action();
|
||||
throw new Error("expected PublicApiAuthError");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PublicApiAuthError);
|
||||
expect(error).toMatchObject(expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe("public API authentication v1 cross-language contract", () => {
|
||||
it("freezes comma/newline parsing, trimming, default ids, and empty filtering", async () => {
|
||||
const fixture = await loadFixture();
|
||||
process.env.ZHINIAN_API_KEYS = fixture.apiKeys;
|
||||
|
||||
expect(fixture.version).toBe(1);
|
||||
expect(getPublicApiClients()).toEqual(fixture.clients);
|
||||
});
|
||||
|
||||
it("freezes public credential transport, precedence, errors, client, and owner", async () => {
|
||||
const fixture = await loadFixture();
|
||||
process.env.ZHINIAN_API_KEYS = fixture.apiKeys;
|
||||
|
||||
for (const testCase of fixture.authenticationCases) {
|
||||
if (testCase.error) {
|
||||
expectTypedError(() => authenticatePublicApiRequest(request(testCase.headers)), testCase.error);
|
||||
continue;
|
||||
}
|
||||
const client = authenticatePublicApiRequest(request(testCase.headers));
|
||||
expect(client, testCase.name).toEqual(testCase.expected?.client);
|
||||
expect(publicApiOwnerId(client), testCase.name).toBe(testCase.expected?.owner);
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes and bounds public owner ids", async () => {
|
||||
const fixture = await loadFixture();
|
||||
for (const testCase of fixture.ownerCases) {
|
||||
const owner = publicApiOwnerId(testCase.id);
|
||||
expect(owner, testCase.id).toBe(testCase.owner);
|
||||
if (testCase.maxPartLength) expect(owner.slice(4)).toHaveLength(testCase.maxPartLength);
|
||||
}
|
||||
});
|
||||
|
||||
it("freezes internal worker development bypass and production failure semantics", async () => {
|
||||
const fixture = await loadFixture();
|
||||
for (const testCase of fixture.workerCases) {
|
||||
Reflect.set(process.env, "NODE_ENV", testCase.production ? "production" : "development");
|
||||
Reflect.set(process.env, "ZHINIAN_INTERNAL_WORKER_TOKEN", testCase.configuredToken);
|
||||
if (testCase.error) {
|
||||
expectTypedError(() => assertInternalWorkerToken(request(testCase.headers)), testCase.error);
|
||||
} else {
|
||||
expect(() => assertInternalWorkerToken(request(testCase.headers)), testCase.name).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user