feat(cache-valkey): initial implementation — Provider, adapters (v1.0.0)
Introduces code.nochebuena.dev/einherjar/cache-valkey — the Valkey cache starter for the Einherjar framework. Absorbs the valkey package from micro-lib and adds three duck-typed adapters that wire directly into auth, web, and auth-jwt. Core: - Provider interface — Get, Set, Del, Exists, Expire, IncrWithTTL, Native() - Component interface — lifecycle.Component + observability.Checkable + Provider - Config struct (EINHERJAR_VALKEY_* env vars) - New(logger, cfg) Component — creates valkey-go client in OnInit; PING in OnStart; logs "valkey: connected" - IncrWithTTL implemented with Lua script (atomic INCR + conditional EXPIRE); race-free fixed-window semantics with no MULTI/EXEC overhead - Priority: LevelDegraded — Valkey outage degrades, does not halt the service Adapters (duck-typed — no import of auth, web, or auth-jwt): - PermissionCache — Get/Set int64 bitmasks as string; satisfies auth/rbac.Cache - RateLimiterStore — fixed-window via IncrWithTTL; satisfies web/mw.RateLimiterStore - Blacklist — Exists/Set "1" with TTL; satisfies auth-jwt.Blacklist Compliance test verifies CT-6, duck-type shape assignments, and full adapter behavioural coverage with a mockProvider (no live server required). - Component interface embeds observability.Identifiable; identifiable.go implements ModulePath and ModuleVersion via runtime/debug.ReadBuildInfo() — prints in launcher banner
This commit is contained in:
366
compliance_test.go
Normal file
366
compliance_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user