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:
2026-05-29 15:58:56 +00:00
commit df7aa63e5c
21 changed files with 2105 additions and 0 deletions

366
compliance_test.go Normal file
View 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)
}
}