사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package authprojection
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func projectionFixture(generation uint64, now time.Time, ttl time.Duration, rawToken string) *iop.PrincipalProjection {
|
|
projection := &iop.PrincipalProjection{
|
|
Generation: generation,
|
|
IssuedAtUnixNano: now.UnixNano(),
|
|
ExpiresAtUnixNano: now.Add(ttl).UnixNano(),
|
|
}
|
|
if rawToken != "" {
|
|
digest := sha256.Sum256([]byte(rawToken))
|
|
projection.Tokens = []*iop.ProjectedPrincipalToken{{
|
|
TokenDigestSha256: hex.EncodeToString(digest[:]),
|
|
PrincipalRef: "principal-1",
|
|
PrincipalAlias: "principal-one",
|
|
TokenRef: "token-1",
|
|
TokenRevision: generation,
|
|
}}
|
|
projection.Routes = []*iop.ProjectedPrincipalRoute{{
|
|
RouteId: "route-1", RouteAlias: "model-1", PrincipalRef: "principal-1",
|
|
CredentialSlotRef: "slot-1", ProfileId: "openai", UpstreamModel: "upstream-model",
|
|
ResourceSelector: "default", RouteRevision: generation, CredentialRevision: generation,
|
|
}}
|
|
}
|
|
return projection
|
|
}
|
|
|
|
func TestCacheAppliesOnlyHigherFreshGeneration(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return now })
|
|
if state := cache.State(); state != StateUnmanaged {
|
|
t.Fatalf("initial state: got %s", state)
|
|
}
|
|
if err := cache.Apply(projectionFixture(4, now, time.Minute, "token-four")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if generation, ok := cache.Generation(); !ok || generation != 4 {
|
|
t.Fatalf("generation: got %d ok=%v", generation, ok)
|
|
}
|
|
if err := cache.Apply(projectionFixture(4, now, time.Minute, "other-token")); !errors.Is(err, ErrGenerationConflict) {
|
|
t.Fatalf("changed equal generation: got %v", err)
|
|
}
|
|
if err := cache.Apply(projectionFixture(3, now, time.Minute, "other-token")); !errors.Is(err, ErrGenerationNotHigher) {
|
|
t.Fatalf("lower generation: got %v", err)
|
|
}
|
|
if err := cache.Apply(projectionFixture(5, now.Add(-2*time.Minute), time.Minute, "expired")); !errors.Is(err, ErrProjectionExpired) {
|
|
t.Fatalf("expired higher generation: got %v", err)
|
|
}
|
|
if err := cache.Apply(projectionFixture(5, now.Add(time.Second), time.Minute, "future")); !errors.Is(err, ErrInvalidProjection) {
|
|
t.Fatalf("not-yet-issued higher generation: got %v", err)
|
|
}
|
|
|
|
digest := sha256.Sum256([]byte("token-four"))
|
|
principal, state, ok := cache.LookupDigest(digest)
|
|
if !ok || state != StateFresh || principal.PrincipalRef != "principal-1" || principal.TokenRef != "token-1" {
|
|
t.Fatalf("lookup: principal=%+v state=%s ok=%v", principal, state, ok)
|
|
}
|
|
if generation, _ := cache.Generation(); generation != 4 {
|
|
t.Fatalf("rejected candidates changed generation to %d", generation)
|
|
}
|
|
}
|
|
|
|
func TestCacheRenewsOnlyIdenticalGeneration(t *testing.T) {
|
|
start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
var clock atomic.Int64
|
|
clock.Store(start.UnixNano())
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return time.Unix(0, clock.Load()) })
|
|
if err := cache.Apply(projectionFixture(7, start, time.Minute, "renewed-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original := cache.current.Load()
|
|
|
|
if err := cache.Apply(projectionFixture(7, start, time.Minute, "renewed-token")); err != nil {
|
|
t.Fatalf("idempotent duplicate: %v", err)
|
|
}
|
|
if got := cache.current.Load(); got != original {
|
|
t.Fatal("identical duplicate replaced the installed snapshot")
|
|
}
|
|
if err := cache.Apply(projectionFixture(7, start.Add(-10*time.Second), 40*time.Second, "renewed-token")); err != nil {
|
|
t.Fatalf("older freshness window: %v", err)
|
|
}
|
|
if got := cache.current.Load(); got != original {
|
|
t.Fatal("older freshness window replaced the installed snapshot")
|
|
}
|
|
|
|
clock.Store(start.Add(10 * time.Second).UnixNano())
|
|
if err := cache.Apply(projectionFixture(7, start.Add(10*time.Second), time.Minute, "changed-token")); !errors.Is(err, ErrGenerationConflict) {
|
|
t.Fatalf("changed equal generation: got %v", err)
|
|
}
|
|
|
|
clock.Store(start.Add(20 * time.Second).UnixNano())
|
|
if err := cache.Apply(projectionFixture(7, start.Add(20*time.Second), time.Minute, "renewed-token")); err != nil {
|
|
t.Fatalf("renewed equal generation: %v", err)
|
|
}
|
|
renewed := cache.current.Load()
|
|
if renewed == original {
|
|
t.Fatal("later freshness window did not replace the installed snapshot")
|
|
}
|
|
if !renewed.issuedAt.After(original.issuedAt) || !renewed.expiresAt.After(original.expiresAt) {
|
|
t.Fatalf("freshness window did not advance: original=%s..%s renewed=%s..%s", original.issuedAt, original.expiresAt, renewed.issuedAt, renewed.expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestCacheRecoversExpiredProjectionWithIdenticalGenerationRenewal(t *testing.T) {
|
|
start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
var clock atomic.Int64
|
|
clock.Store(start.UnixNano())
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return time.Unix(0, clock.Load()) })
|
|
if err := cache.Apply(projectionFixture(9, start, time.Minute, "recovery-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recoveredAt := start.Add(2 * time.Minute)
|
|
clock.Store(recoveredAt.UnixNano())
|
|
if state := cache.State(); state != StateExpired {
|
|
t.Fatalf("state before renewal: got %s", state)
|
|
}
|
|
if err := cache.Apply(projectionFixture(9, recoveredAt, time.Minute, "recovery-token")); err != nil {
|
|
t.Fatalf("same-generation expiry recovery: %v", err)
|
|
}
|
|
if state := cache.State(); state != StateFresh {
|
|
t.Fatalf("state after renewal: got %s", state)
|
|
}
|
|
}
|
|
|
|
func TestCacheExpiryKeepsManagedFailClosed(t *testing.T) {
|
|
start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
var clock atomic.Int64
|
|
clock.Store(start.UnixNano())
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return time.Unix(0, clock.Load()) })
|
|
if err := cache.Apply(projectionFixture(1, start, time.Minute, "expiring-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clock.Store(start.Add(time.Minute).UnixNano())
|
|
if state := cache.State(); state != StateExpired {
|
|
t.Fatalf("expired state: got %s", state)
|
|
}
|
|
digest := sha256.Sum256([]byte("expiring-token"))
|
|
if _, state, ok := cache.LookupDigest(digest); ok || state != StateExpired {
|
|
t.Fatalf("expired lookup: state=%s ok=%v", state, ok)
|
|
}
|
|
|
|
recoveredAt := start.Add(2 * time.Minute)
|
|
clock.Store(recoveredAt.UnixNano())
|
|
if err := cache.Apply(projectionFixture(2, recoveredAt, time.Minute, "replacement-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state := cache.State(); state != StateFresh {
|
|
t.Fatalf("recovered state: got %s", state)
|
|
}
|
|
}
|
|
|
|
func TestCacheRevocationSnapshotRemovesVerifier(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return now })
|
|
if err := cache.Apply(projectionFixture(1, now, time.Minute, "revoked-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cache.Apply(projectionFixture(2, now, time.Minute, "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256([]byte("revoked-token"))
|
|
if _, state, ok := cache.LookupDigest(digest); ok || state != StateFresh {
|
|
t.Fatalf("revoked lookup: state=%s ok=%v", state, ok)
|
|
}
|
|
if state := cache.State(); state != StateFresh {
|
|
t.Fatalf("revocation snapshot must remain managed and fresh: %s", state)
|
|
}
|
|
}
|
|
|
|
func TestCacheRejectsOversizedSnapshot(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
projection := projectionFixture(1, now, time.Minute, "token-one")
|
|
second := projectionFixture(1, now, time.Minute, "token-two").GetTokens()[0]
|
|
second.TokenRef = "token-2"
|
|
second.PrincipalRef = "principal-2"
|
|
projection.Tokens = append(projection.Tokens, second)
|
|
|
|
cache := NewCache(Limits{MaxTokens: 1, MaxRoutes: 4, MaxBytes: 4096}, func() time.Time { return now })
|
|
if err := cache.Apply(projection); !errors.Is(err, ErrProjectionTooLarge) {
|
|
t.Fatalf("token limit: got %v", err)
|
|
}
|
|
byteCache := NewCache(Limits{MaxTokens: 4, MaxRoutes: 4, MaxBytes: 1}, func() time.Time { return now })
|
|
if err := byteCache.Apply(projectionFixture(1, now, time.Minute, "token-one")); !errors.Is(err, ErrProjectionTooLarge) {
|
|
t.Fatalf("byte limit: got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCacheDeepCopiesAppliedProjection(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return now })
|
|
projection := projectionFixture(1, now, time.Minute, "immutable-token")
|
|
if err := cache.Apply(projection); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projection.Tokens[0].PrincipalRef = "mutated"
|
|
projection.Tokens[0].TokenDigestSha256 = strings.Repeat("0", sha256.Size*2)
|
|
projection.Routes[0].RouteAlias = "mutated"
|
|
projection.ExpiresAtUnixNano = now.Add(-time.Minute).UnixNano()
|
|
|
|
digest := sha256.Sum256([]byte("immutable-token"))
|
|
principal, state, ok := cache.LookupDigest(digest)
|
|
if !ok || state != StateFresh || principal.PrincipalRef != "principal-1" {
|
|
t.Fatalf("mutated input changed cache: principal=%+v state=%s ok=%v", principal, state, ok)
|
|
}
|
|
routes, state := cache.RoutesForPrincipal("principal-1")
|
|
if state != StateFresh || len(routes) != 1 || routes[0].RouteAlias != "model-1" {
|
|
t.Fatalf("mutated route changed cache: routes=%+v state=%s", routes, state)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticatedViewRetainsOneCopiedGeneration(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return now })
|
|
if err := cache.Apply(projectionFixture(1, now, time.Hour, "view-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256([]byte("view-token"))
|
|
view, ok := cache.AuthenticatedView(digest)
|
|
if !ok || view.Generation != 1 || len(view.Routes) != 1 {
|
|
t.Fatalf("view: %+v ok=%v", view, ok)
|
|
}
|
|
view.Routes[0].RouteID = "caller-mutation"
|
|
if err := cache.Apply(projectionFixture(2, now, time.Hour, "next-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.Generation != 1 || view.Routes[0].RouteID != "caller-mutation" {
|
|
t.Fatalf("view changed after swap: %+v", view)
|
|
}
|
|
routes, _ := cache.RoutesForPrincipal("principal-1")
|
|
if len(routes) != 1 || routes[0].RouteID != "route-1" {
|
|
t.Fatalf("view mutation changed cache routes: %+v", routes)
|
|
}
|
|
}
|
|
|
|
func TestCacheConcurrentLookupAndApply(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return now })
|
|
if err := cache.Apply(projectionFixture(0, now, time.Hour, "token-0")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for reader := 0; reader < 8; reader++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 500; i++ {
|
|
digest := sha256.Sum256([]byte("token-0"))
|
|
_, state, _ := cache.LookupDigest(digest)
|
|
if state == StateUnmanaged {
|
|
t.Errorf("managed cache returned unmanaged state")
|
|
return
|
|
}
|
|
_, _ = cache.RoutesForPrincipal("principal-1")
|
|
}
|
|
}()
|
|
}
|
|
for writer := 0; writer < 4; writer++ {
|
|
writer := writer
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for generation := uint64(writer + 1); generation <= 200; generation += 4 {
|
|
err := cache.Apply(projectionFixture(generation, now, time.Hour, "token-0"))
|
|
if err != nil && !errors.Is(err, ErrGenerationNotHigher) {
|
|
t.Errorf("apply generation %d: %v", generation, err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if generation, ok := cache.Generation(); !ok || generation != 200 {
|
|
t.Fatalf("final generation: got %d ok=%v", generation, ok)
|
|
}
|
|
}
|
|
|
|
func TestCacheConcurrentSameGenerationRenewalAndReaders(t *testing.T) {
|
|
start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
now := start.Add(5 * time.Minute)
|
|
cache := NewCache(DefaultLimits(), func() time.Time { return now })
|
|
if err := cache.Apply(projectionFixture(11, start, 10*time.Minute, "stable-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for reader := 0; reader < 8; reader++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
digest := sha256.Sum256([]byte("stable-token"))
|
|
for range 500 {
|
|
if _, state, ok := cache.LookupDigest(digest); !ok || state != StateFresh {
|
|
t.Errorf("renewal reader observed state=%s ok=%v", state, ok)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
for writer := 0; writer < 4; writer++ {
|
|
writer := writer
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for offset := writer + 1; offset <= 100; offset += 4 {
|
|
issuedAt := start.Add(time.Duration(offset) * time.Second)
|
|
if err := cache.Apply(projectionFixture(11, issuedAt, 10*time.Minute, "stable-token")); err != nil {
|
|
t.Errorf("same-generation renewal at %s: %v", issuedAt, err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
installed := cache.current.Load()
|
|
wantIssuedAt := start.Add(100 * time.Second)
|
|
if !installed.issuedAt.Equal(wantIssuedAt) {
|
|
t.Fatalf("final renewal issued_at: got %s want %s", installed.issuedAt, wantIssuedAt)
|
|
}
|
|
}
|