Files
multica/server/internal/auth/membership_cache_test.go
Zohar Babin 15152c6ccd feat(auth): cache workspace membership for daemon heartbeat path (MUL-2247) (#2638)
* feat(auth): cache workspace membership for daemon heartbeat path

Cache workspace membership existence (not role) in Redis to eliminate a
DB round-trip on every PAT-authenticated daemon heartbeat. Follows the
existing PATCache nil-safe pattern.

Key design decisions per reviewer feedback:
- Cache existence only (sentinel "1"), not role string. Authorization
  decisions that depend on role always hit the DB directly. This
  eliminates the cache-aside race where a stale elevated role could
  persist after a downgrade.
- Proactive invalidation on UpdateMember, DeleteMember, LeaveWorkspace,
  and DeleteWorkspace (iterates members before cascade delete).
- 5 min TTL. Combined with PATCache (10 min), worst-case revocation
  delay is max(10m, 5m) = 10 min — consistent with original PATCache
  design decision.

Limitations:
- Non-members still hit DB on every request (negative caching not
  implemented — the scenario is rare for daemon endpoints which require
  valid workspace-scoped tokens).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(auth): drive membership cache invalidation through real handlers

- TestRequireDaemonWorkspaceAccess_CacheHit now uses a ghost user with no
  member row, so the only path to a granted access is the cache short-circuit.
  Without priming the cache the access check must fail; with priming it must
  succeed. A future change that bypasses the cache would fail the second
  assertion.
- Replaces the cache-only InvalidatedOnMemberRemoval test (which only
  re-exercised the auth-package primitive) with four handler-driven tests
  that exercise DeleteMember, UpdateMember, LeaveWorkspace and
  DeleteWorkspace via their real HTTP handlers. Each test prepares a real
  member, primes the cache, calls the handler, and asserts the cache entry
  is gone — so a refactor that drops one of the Invalidate(...) calls in
  workspace.go will fail CI.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-05-18 13:30:35 +08:00

93 lines
2.1 KiB
Go

package auth
import (
"context"
"testing"
"time"
)
func TestMembershipCache_NilSafe(t *testing.T) {
var c *MembershipCache // nil
ctx := context.Background()
if c.Get(ctx, "any-user", "any-workspace") {
t.Fatal("nil cache must miss")
}
c.Set(ctx, "any-user", "any-workspace") // no panic
c.Invalidate(ctx, "any-user", "any-workspace") // no panic
}
func TestNewMembershipCache_NilRedisReturnsNil(t *testing.T) {
if c := NewMembershipCache(nil); c != nil {
t.Fatalf("NewMembershipCache(nil) must return nil, got %#v", c)
}
}
func TestMembershipCache_SetGetInvalidate(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewMembershipCache(rdb)
if c == nil {
t.Fatal("NewMembershipCache returned nil")
}
ctx := context.Background()
if c.Get(ctx, "user-1", "ws-1") {
t.Fatal("expected miss before set")
}
c.Set(ctx, "user-1", "ws-1")
if !c.Get(ctx, "user-1", "ws-1") {
t.Fatal("expected hit after set")
}
c.Invalidate(ctx, "user-1", "ws-1")
if c.Get(ctx, "user-1", "ws-1") {
t.Fatal("expected miss after invalidate")
}
}
func TestMembershipCache_TTL(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewMembershipCache(rdb)
if c == nil {
t.Fatal("NewMembershipCache returned nil")
}
ctx := context.Background()
c.Set(ctx, "user-T", "ws-T")
ttl, err := rdb.TTL(ctx, membershipKey("user-T", "ws-T")).Result()
if err != nil {
t.Fatalf("TTL: %v", err)
}
if ttl <= 0 || ttl > MembershipCacheTTL+time.Second {
t.Fatalf("unexpected TTL %v (want ~%v)", ttl, MembershipCacheTTL)
}
}
func TestMembershipCache_IsolatesKeysByUser(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewMembershipCache(rdb)
if c == nil {
t.Fatal("NewMembershipCache returned nil")
}
ctx := context.Background()
c.Set(ctx, "user-A", "ws-1")
c.Set(ctx, "user-B", "ws-1")
if !c.Get(ctx, "user-A", "ws-1") {
t.Fatal("user-A should be cached")
}
if !c.Get(ctx, "user-B", "ws-1") {
t.Fatal("user-B should be cached")
}
c.Invalidate(ctx, "user-A", "ws-1")
if c.Get(ctx, "user-A", "ws-1") {
t.Fatal("user-A should be invalidated")
}
if !c.Get(ctx, "user-B", "ws-1") {
t.Fatal("user-B should still be cached")
}
}