package cachevalkey_test import ( "context" "errors" "go/ast" "go/parser" "go/token" "os" "strings" "testing" "time" vk "github.com/valkey-io/valkey-go" cachevalkey "code.nochebuena.dev/einherjar/cache-valkey" "code.nochebuena.dev/einherjar/contracts/lifecycle" "code.nochebuena.dev/einherjar/contracts/observability" ) // --------------------------------------------------------------------------- // CT-6: ≤1 exported TypeSpec per file // --------------------------------------------------------------------------- func TestAtMostOneExportedTypePerFile(t *testing.T) { fset := token.NewFileSet() entries, err := os.ReadDir(".") if err != nil { t.Fatalf("ReadDir: %v", err) } for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } f, err := parser.ParseFile(fset, name, nil, 0) if err != nil { t.Fatalf("parse %s: %v", name, err) } count := 0 for _, decl := range f.Decls { gd, ok := decl.(*ast.GenDecl) if !ok { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if !ok { continue } if ts.Name.IsExported() { count++ } } } if count > 1 { t.Errorf("%s: has %d exported TypeSpecs, want ≤1", name, count) } } } // --------------------------------------------------------------------------- // Duck-type shape checks // // These shape interfaces mirror the target consumer interfaces exactly. // If this block compiles, each adapter is wire-compatible with its target module. // In application code, pass adapters directly — no cast or glue interface needed: // // rbac.NewCachedPermissionProvider(cachevalkey.NewPermissionCache(vk), ...) // mw.IPRateLimit(cachevalkey.NewRateLimiterStore(vk, window, limit), ...) // authjwt.RefreshTokenPair(ctx, signer, token, cachevalkey.NewBlacklist(vk), ...) // --------------------------------------------------------------------------- type rbacCacheShape interface { Get(ctx context.Context, key string) (int64, bool, error) Set(ctx context.Context, key string, value int64, ttl time.Duration) error } type rateLimiterStoreShape interface { Allow(ctx context.Context, key string) (bool, error) } type blacklistShape interface { IsRevoked(ctx context.Context, jti string) (bool, error) Revoke(ctx context.Context, jti string, ttl time.Duration) error } var _ rbacCacheShape = (*cachevalkey.PermissionCache)(nil) var _ rateLimiterStoreShape = (*cachevalkey.RateLimiterStore)(nil) var _ blacklistShape = (*cachevalkey.Blacklist)(nil) // --------------------------------------------------------------------------- // Compile-time: Component embeds lifecycle.Component and observability.Checkable // --------------------------------------------------------------------------- func _() { var c cachevalkey.Component var _ lifecycle.Component = c var _ observability.Checkable = c } // --------------------------------------------------------------------------- // mockProvider — in-process Provider for adapter behavioral tests // --------------------------------------------------------------------------- type mockProvider struct { getResult string getFound bool getErr error setErr error setKey string setVal string setTTL time.Duration delErr error existsResult bool existsErr error expireErr error incrResult int64 incrErr error incrKey string incrTTL time.Duration } func (m *mockProvider) Get(_ context.Context, _ string) (string, bool, error) { return m.getResult, m.getFound, m.getErr } func (m *mockProvider) Set(_ context.Context, key, val string, ttl time.Duration) error { m.setKey = key m.setVal = val m.setTTL = ttl return m.setErr } func (m *mockProvider) Del(_ context.Context, _ ...string) error { return m.delErr } func (m *mockProvider) Exists(_ context.Context, _ string) (bool, error) { return m.existsResult, m.existsErr } func (m *mockProvider) Expire(_ context.Context, _ string, _ time.Duration) error { return m.expireErr } func (m *mockProvider) IncrWithTTL(_ context.Context, key string, ttl time.Duration) (int64, error) { m.incrKey = key m.incrTTL = ttl return m.incrResult, m.incrErr } func (m *mockProvider) Native() vk.Client { return nil } // --------------------------------------------------------------------------- // PermissionCache tests // --------------------------------------------------------------------------- func TestPermissionCache_Get_Hit(t *testing.T) { p := &mockProvider{getResult: "42", getFound: true} c := cachevalkey.NewPermissionCache(p) val, ok, err := c.Get(context.Background(), "key") if err != nil { t.Fatalf("unexpected error: %v", err) } if !ok { t.Fatal("expected found=true") } if val != 42 { t.Fatalf("got %d, want 42", val) } } func TestPermissionCache_Get_Miss(t *testing.T) { p := &mockProvider{getFound: false} c := cachevalkey.NewPermissionCache(p) val, ok, err := c.Get(context.Background(), "key") if err != nil { t.Fatalf("unexpected error: %v", err) } if ok { t.Fatal("expected found=false") } if val != 0 { t.Fatalf("got %d, want 0", val) } } func TestPermissionCache_Get_ParseError(t *testing.T) { p := &mockProvider{getResult: "not-a-number", getFound: true} c := cachevalkey.NewPermissionCache(p) _, _, err := c.Get(context.Background(), "key") if err == nil { t.Fatal("expected parse error") } } func TestPermissionCache_Get_StoreError(t *testing.T) { storeErr := errors.New("connection reset") p := &mockProvider{getErr: storeErr} c := cachevalkey.NewPermissionCache(p) _, _, err := c.Get(context.Background(), "key") if !errors.Is(err, storeErr) { t.Fatalf("got %v, want %v", err, storeErr) } } func TestPermissionCache_Set(t *testing.T) { p := &mockProvider{} c := cachevalkey.NewPermissionCache(p) ttl := 5 * time.Minute if err := c.Set(context.Background(), "mykey", 99, ttl); err != nil { t.Fatalf("unexpected error: %v", err) } if p.setKey != "mykey" { t.Errorf("key: got %q, want %q", p.setKey, "mykey") } if p.setVal != "99" { t.Errorf("val: got %q, want %q", p.setVal, "99") } if p.setTTL != ttl { t.Errorf("ttl: got %v, want %v", p.setTTL, ttl) } } func TestPermissionCache_Set_Error(t *testing.T) { storeErr := errors.New("write timeout") p := &mockProvider{setErr: storeErr} c := cachevalkey.NewPermissionCache(p) err := c.Set(context.Background(), "k", 1, time.Minute) if !errors.Is(err, storeErr) { t.Fatalf("got %v, want %v", err, storeErr) } } // --------------------------------------------------------------------------- // RateLimiterStore tests // --------------------------------------------------------------------------- func TestRateLimiterStore_Allow_UnderLimit(t *testing.T) { p := &mockProvider{incrResult: 1} s := cachevalkey.NewRateLimiterStore(p, time.Second, 10) ok, err := s.Allow(context.Background(), "ip:1.2.3.4") if err != nil { t.Fatalf("unexpected error: %v", err) } if !ok { t.Fatal("expected allowed=true") } } func TestRateLimiterStore_Allow_AtLimit(t *testing.T) { p := &mockProvider{incrResult: 10} s := cachevalkey.NewRateLimiterStore(p, time.Second, 10) ok, err := s.Allow(context.Background(), "ip:1.2.3.4") if err != nil { t.Fatalf("unexpected error: %v", err) } if !ok { t.Fatal("expected allowed=true (at limit is still allowed)") } } func TestRateLimiterStore_Allow_OverLimit(t *testing.T) { p := &mockProvider{incrResult: 11} s := cachevalkey.NewRateLimiterStore(p, time.Second, 10) ok, err := s.Allow(context.Background(), "ip:1.2.3.4") if err != nil { t.Fatalf("unexpected error: %v", err) } if ok { t.Fatal("expected allowed=false (over limit)") } } func TestRateLimiterStore_Allow_Error(t *testing.T) { storeErr := errors.New("valkey unavailable") p := &mockProvider{incrErr: storeErr} s := cachevalkey.NewRateLimiterStore(p, time.Second, 10) ok, err := s.Allow(context.Background(), "key") if err == nil { t.Fatal("expected error") } if ok { t.Fatal("expected allowed=false on error") } } func TestRateLimiterStore_Allow_PassesWindowToIncrWithTTL(t *testing.T) { p := &mockProvider{incrResult: 1} window := 30 * time.Second s := cachevalkey.NewRateLimiterStore(p, window, 100) _, _ = s.Allow(context.Background(), "key") if p.incrTTL != window { t.Errorf("window: got %v, want %v", p.incrTTL, window) } } // --------------------------------------------------------------------------- // Blacklist tests // --------------------------------------------------------------------------- func TestBlacklist_IsRevoked_True(t *testing.T) { p := &mockProvider{existsResult: true} b := cachevalkey.NewBlacklist(p) ok, err := b.IsRevoked(context.Background(), "jti-abc") if err != nil { t.Fatalf("unexpected error: %v", err) } if !ok { t.Fatal("expected revoked=true") } } func TestBlacklist_IsRevoked_False(t *testing.T) { p := &mockProvider{existsResult: false} b := cachevalkey.NewBlacklist(p) ok, err := b.IsRevoked(context.Background(), "jti-abc") if err != nil { t.Fatalf("unexpected error: %v", err) } if ok { t.Fatal("expected revoked=false") } } func TestBlacklist_IsRevoked_Error(t *testing.T) { storeErr := errors.New("connection refused") p := &mockProvider{existsErr: storeErr} b := cachevalkey.NewBlacklist(p) _, err := b.IsRevoked(context.Background(), "jti-abc") if !errors.Is(err, storeErr) { t.Fatalf("got %v, want %v", err, storeErr) } } func TestBlacklist_Revoke(t *testing.T) { p := &mockProvider{} b := cachevalkey.NewBlacklist(p) ttl := 2 * time.Hour if err := b.Revoke(context.Background(), "jti-xyz", ttl); err != nil { t.Fatalf("unexpected error: %v", err) } if p.setKey != "jti-xyz" { t.Errorf("key: got %q, want %q", p.setKey, "jti-xyz") } if p.setVal != "1" { t.Errorf("val: got %q, want %q", p.setVal, "1") } if p.setTTL != ttl { t.Errorf("ttl: got %v, want %v", p.setTTL, ttl) } } func TestBlacklist_Revoke_Error(t *testing.T) { storeErr := errors.New("write failed") p := &mockProvider{setErr: storeErr} b := cachevalkey.NewBlacklist(p) err := b.Revoke(context.Background(), "jti-xyz", time.Hour) if !errors.Is(err, storeErr) { t.Fatalf("got %v, want %v", err, storeErr) } }