mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* 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>
93 lines
2.1 KiB
Go
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")
|
|
}
|
|
}
|