Files
goclaw/internal/http/api_key_cache_test.go
T
Goon 75c570e951 feat(security): credentialed exec + HTTP RBAC + API key cache (#197)
- Secure CLI credential injection via AES-256-GCM encrypted env vars
- API key management with fine-grained RBAC scopes
- resolveAuth/requireAuth middleware across all 25+ HTTP handlers
- In-memory API key cache with TTL, negative caching, pubsub invalidation
- Sandbox-first execution (fails if unavailable, no silent fallback)
- Credential scrubbing, constant-time token comparison, Admin-only CLI creds
- SQL migration 000020: secure_cli_binaries + api_keys tables
- 14 unit tests for cache and RBAC with race detector

Closes #197
2026-03-15 20:13:18 +07:00

216 lines
5.4 KiB
Go

package http
import (
"context"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mockAPIKeyStore is a minimal mock for testing the cache layer.
type mockAPIKeyStore struct {
mu sync.Mutex
keys map[string]*store.APIKeyData // hash → key
calls int // GetByHash call count
touchedID uuid.UUID // last TouchLastUsed ID
}
func newMockAPIKeyStore() *mockAPIKeyStore {
return &mockAPIKeyStore{keys: make(map[string]*store.APIKeyData)}
}
func (m *mockAPIKeyStore) GetByHash(_ context.Context, hash string) (*store.APIKeyData, error) {
m.mu.Lock()
m.calls++
m.mu.Unlock()
if k, ok := m.keys[hash]; ok {
return k, nil
}
return nil, nil
}
func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error {
m.mu.Lock()
m.touchedID = id
m.mu.Unlock()
return nil
}
func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil }
func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil }
func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (m *mockAPIKeyStore) getCalls() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.calls
}
func TestCacheMissFetchesFromStore(t *testing.T) {
ms := newMockAPIKeyStore()
keyID := uuid.New()
ms.keys["hash123"] = &store.APIKeyData{
ID: keyID,
Scopes: []string{"operator.read"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
key, role := c.getOrFetch(context.Background(), "hash123")
if key == nil {
t.Fatal("expected key, got nil")
}
if key.ID != keyID {
t.Errorf("key ID = %v, want %v", key.ID, keyID)
}
if role != permissions.RoleViewer {
t.Errorf("role = %v, want %v", role, permissions.RoleViewer)
}
if ms.getCalls() != 1 {
t.Errorf("store calls = %d, want 1", ms.getCalls())
}
}
func TestCacheHitReturnsWithoutStoreCall(t *testing.T) {
ms := newMockAPIKeyStore()
keyID := uuid.New()
ms.keys["hash456"] = &store.APIKeyData{
ID: keyID,
Scopes: []string{"operator.admin"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
// First call: cache miss → store fetch
c.getOrFetch(context.Background(), "hash456")
// Second call: cache hit → no store fetch
key, role := c.getOrFetch(context.Background(), "hash456")
if key == nil {
t.Fatal("expected key on cache hit, got nil")
}
if role != permissions.RoleAdmin {
t.Errorf("role = %v, want %v", role, permissions.RoleAdmin)
}
if ms.getCalls() != 1 {
t.Errorf("store calls = %d, want 1 (cache hit should not call store)", ms.getCalls())
}
}
func TestCacheTTLExpiry(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["hash789"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.write"},
}
c := newAPIKeyCache(ms, 10*time.Millisecond) // very short TTL
// First fetch
c.getOrFetch(context.Background(), "hash789")
if ms.getCalls() != 1 {
t.Fatalf("initial calls = %d, want 1", ms.getCalls())
}
// Wait for TTL to expire
time.Sleep(20 * time.Millisecond)
// Should re-fetch from store
key, _ := c.getOrFetch(context.Background(), "hash789")
if key == nil {
t.Fatal("expected key after TTL expiry")
}
if ms.getCalls() != 2 {
t.Errorf("store calls after TTL = %d, want 2", ms.getCalls())
}
}
func TestCacheInvalidateAll(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["hashA"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.read"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
// Populate cache
c.getOrFetch(context.Background(), "hashA")
if ms.getCalls() != 1 {
t.Fatalf("initial calls = %d, want 1", ms.getCalls())
}
// Invalidate
c.invalidateAll()
// Should fetch again from store
c.getOrFetch(context.Background(), "hashA")
if ms.getCalls() != 2 {
t.Errorf("store calls after invalidate = %d, want 2", ms.getCalls())
}
}
func TestCacheNegativeCache(t *testing.T) {
ms := newMockAPIKeyStore()
// No keys in store
c := newAPIKeyCache(ms, 5*time.Minute)
// First lookup: cache miss → store returns nil → negative cache
key1, _ := c.getOrFetch(context.Background(), "unknown")
if key1 != nil {
t.Fatal("expected nil key for unknown hash")
}
if ms.getCalls() != 1 {
t.Fatalf("initial calls = %d, want 1", ms.getCalls())
}
// Second lookup: negative cache hit → no store call
key2, _ := c.getOrFetch(context.Background(), "unknown")
if key2 != nil {
t.Fatal("expected nil key on negative cache hit")
}
if ms.getCalls() != 1 {
t.Errorf("store calls after negative cache = %d, want 1", ms.getCalls())
}
}
func TestCacheConcurrentAccess(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["concurrent"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.admin"},
}
c := newAPIKeyCache(ms, 5*time.Minute)
var wg sync.WaitGroup
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
key, role := c.getOrFetch(context.Background(), "concurrent")
if key == nil {
t.Error("expected key, got nil")
}
if role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin", role)
}
}()
}
wg.Wait()
// Store should have been called at least once, but not 50 times
// (cache may have been populated after the first call)
calls := ms.getCalls()
if calls == 0 || calls > 50 {
t.Errorf("store calls = %d, want 1..50", calls)
}
}