Files
multica/server/internal/auth/pat_cache_test.go
Bohan Jiang 81bde585ba MUL-3467: batch load squad roster skills (#4386)
* MUL-3467: batch load squad roster skills

Co-authored-by: multica-agent <github@multica.ai>

* test: isolate redis-backed test databases

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 13:14:22 +08:00

171 lines
4.9 KiB
Go

package auth
import (
"context"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
const redisTestDB = 11
// newRedisTestClient connects to REDIS_TEST_URL, uses this package's logical
// test DB, flushes, and skips when unset so `go test ./...` works on a stock
// laptop without a Redis instance running.
func newRedisTestClient(t *testing.T) *redis.Client {
t.Helper()
url := os.Getenv("REDIS_TEST_URL")
if url == "" {
t.Skip("REDIS_TEST_URL not set")
}
opts, err := redis.ParseURL(url)
if err != nil {
t.Fatalf("parse REDIS_TEST_URL: %v", err)
}
opts.DB = redisTestDB
rdb := redis.NewClient(opts)
ctx := context.Background()
if err := rdb.Ping(ctx).Err(); err != nil {
t.Skipf("REDIS_TEST_URL unreachable: %v", err)
}
if err := rdb.FlushDB(ctx).Err(); err != nil {
t.Fatalf("flushdb: %v", err)
}
t.Cleanup(func() {
rdb.FlushDB(context.Background())
rdb.Close()
})
return rdb
}
func TestPATCache_NilSafe(t *testing.T) {
var c *PATCache // nil
ctx := context.Background()
if v, ok := c.Get(ctx, "any-hash"); ok || v != "" {
t.Fatalf("nil cache must miss; got (%q, %v)", v, ok)
}
c.Set(ctx, "any-hash", "user-1", AuthCacheTTL) // no panic
c.Invalidate(ctx, "any-hash") // no panic
}
func TestNewPATCache_NilRedisReturnsNil(t *testing.T) {
if c := NewPATCache(nil); c != nil {
t.Fatalf("NewPATCache(nil) must return nil, got %#v", c)
}
}
func TestPATCache_SetGetInvalidate(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewPATCache(rdb)
if c == nil {
t.Fatal("NewPATCache returned nil")
}
ctx := context.Background()
if _, ok := c.Get(ctx, "missing"); ok {
t.Fatal("expected miss before set")
}
c.Set(ctx, "hash-A", "user-A", AuthCacheTTL)
if v, ok := c.Get(ctx, "hash-A"); !ok || v != "user-A" {
t.Fatalf("expected hit user-A, got (%q, %v)", v, ok)
}
c.Invalidate(ctx, "hash-A")
if v, ok := c.Get(ctx, "hash-A"); ok {
t.Fatalf("expected miss after invalidate, got (%q, %v)", v, ok)
}
}
// TestPATCache_TTL pins the contract that entries expire on AuthCacheTTL so
// the auth middleware refreshes last_used_at at most once per window.
//
// We don't sleep AuthCacheTTL (60s); instead we assert the TTL is what the
// constructor set, which is the property the middleware actually depends
// on.
func TestPATCache_TTL(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewPATCache(rdb)
if c == nil {
t.Fatal("NewPATCache returned nil")
}
ctx := context.Background()
c.Set(ctx, "hash-T", "user-T", AuthCacheTTL)
ttl, err := rdb.TTL(ctx, patCacheKey("hash-T")).Result()
if err != nil {
t.Fatalf("TTL: %v", err)
}
// Redis returns the remaining TTL; allow a small skew for rounding.
if ttl <= 0 || ttl > AuthCacheTTL+time.Second {
t.Fatalf("unexpected TTL %v (want ~%v)", ttl, AuthCacheTTL)
}
}
func TestTTLForExpiry(t *testing.T) {
now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
// No expiry set → full AuthCacheTTL.
if got := TTLForExpiry(now, time.Time{}); got != AuthCacheTTL {
t.Fatalf("zero expires_at: got %v, want %v", got, AuthCacheTTL)
}
// Far-future expiry → full AuthCacheTTL.
far := now.Add(24 * time.Hour)
if got := TTLForExpiry(now, far); got != AuthCacheTTL {
t.Fatalf("far-future expires_at: got %v, want %v", got, AuthCacheTTL)
}
// Sooner-than-TTL expiry → clamped to remaining lifetime.
soon := now.Add(10 * time.Second)
if got := TTLForExpiry(now, soon); got != 10*time.Second {
t.Fatalf("sooner expires_at: got %v, want 10s", got)
}
// Already expired (or exactly now) → 0, caller skips caching.
if got := TTLForExpiry(now, now); got != 0 {
t.Fatalf("expires_at == now: got %v, want 0", got)
}
if got := TTLForExpiry(now, now.Add(-time.Second)); got != 0 {
t.Fatalf("past expires_at: got %v, want 0", got)
}
}
// TestPATCache_Set_RespectsClampedTTL is the regression test for the
// review finding: a PAT expiring in <AuthCacheTTL must NOT be cached for
// the full AuthCacheTTL window, otherwise it would continue passing auth
// on cache hit after expires_at.
func TestPATCache_Set_RespectsClampedTTL(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewPATCache(rdb)
if c == nil {
t.Fatal("NewPATCache returned nil")
}
ctx := context.Background()
// Cache with a 5s TTL — what TTLForExpiry would return for a token
// expiring 5s from now.
c.Set(ctx, "hash-short", "user-short", 5*time.Second)
ttl, err := rdb.TTL(ctx, patCacheKey("hash-short")).Result()
if err != nil {
t.Fatalf("TTL: %v", err)
}
if ttl <= 0 || ttl > 5*time.Second+time.Second {
t.Fatalf("expected clamped TTL ~5s, got %v", ttl)
}
// Zero / negative TTL must skip caching entirely (already-expired
// token's TOCTOU-safe path).
c.Set(ctx, "hash-zero", "user-zero", 0)
if _, ok := c.Get(ctx, "hash-zero"); ok {
t.Fatal("zero-TTL Set must not cache")
}
c.Set(ctx, "hash-neg", "user-neg", -time.Second)
if _, ok := c.Get(ctx, "hash-neg"); ok {
t.Fatal("negative-TTL Set must not cache")
}
}