367 lines
10 KiB
Go
367 lines
10 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|