Files
goclaw/internal/http/skills_cache_invalidate_test.go
viettranx d77a3664db fix(cache): tenant-aware invalidation for builtin tools and skills
Tenant config changes for builtin tools and skills silently failed to
invalidate cached agent Loops, leaving tenants stuck on stale tool/skill
sets until the 10-minute TTL expired or an unrelated event wiped the
cache. Master-level skill CRUD had the same gap in the opposite
direction.

- Add CacheInvalidatePayload.TenantID so events can scope to one tenant
- Add Router.InvalidateTenant(tenantID) with prefix match on
  "tenantID:agentKey" cache keys; uuid.Nil is a no-op
- Rework emitCacheInvalidate helpers in builtin_tools + skills handlers
  to carry tenant scope; add defense-in-depth uuid.Nil guards in four
  tenant-config handlers
- Update TopicCacheBuiltinTools + TopicCacheSkills subscribers to branch
  on payload.TenantID (tenant event wipes that tenant, global event
  keeps the existing InvalidateAll path)
- Wire emitCacheInvalidate into master skill CRUD paths (update, delete,
  toggle, upload, install-deps, rescan-deps, import) that previously
  only called BumpVersion
- Document the system-owner bypass in requireTenantAdmin so handlers
  keep guarding uuid.Nil themselves
2026-04-11 23:22:17 +07:00

82 lines
2.3 KiB
Go

package http
import (
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// TestSkillsHandler_EmitCacheInvalidate_TenantScopedPropagatesTenantID
// verifies tenant-config handlers emit payloads carrying the caller's tenant.
func TestSkillsHandler_EmitCacheInvalidate_TenantScopedPropagatesTenantID(t *testing.T) {
mb := bus.New()
defer mb.Close()
h := &SkillsHandler{msgBus: mb}
var captured bus.CacheInvalidatePayload
var gotEvent bool
mb.Subscribe("test-capture", func(e bus.Event) {
if e.Name != protocol.EventCacheInvalidate {
return
}
if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok {
captured = p
gotEvent = true
}
})
tid := uuid.New()
skillID := uuid.New().String()
h.emitCacheInvalidate(bus.CacheKindSkills, skillID, tid)
if !gotEvent {
t.Fatal("no cache invalidate event received")
}
if captured.Kind != bus.CacheKindSkills {
t.Errorf("kind = %q, want %q", captured.Kind, bus.CacheKindSkills)
}
if captured.Key != skillID {
t.Errorf("key = %q, want %q", captured.Key, skillID)
}
if captured.TenantID != tid {
t.Errorf("tenantID = %v, want %v", captured.TenantID, tid)
}
}
// TestSkillsHandler_EmitCacheInvalidate_GrantsGlobal verifies grant-change
// callers pass uuid.Nil so subscribers treat the event as global.
func TestSkillsHandler_EmitCacheInvalidate_GrantsGlobal(t *testing.T) {
mb := bus.New()
defer mb.Close()
h := &SkillsHandler{msgBus: mb}
var captured bus.CacheInvalidatePayload
mb.Subscribe("test-capture", func(e bus.Event) {
if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok {
captured = p
}
})
h.emitCacheInvalidate(bus.CacheKindSkillGrants, "", uuid.Nil)
if captured.Kind != bus.CacheKindSkillGrants {
t.Errorf("kind = %q, want %q", captured.Kind, bus.CacheKindSkillGrants)
}
if captured.TenantID != uuid.Nil {
t.Errorf("tenantID = %v, want uuid.Nil for grant-change global event", captured.TenantID)
}
}
// TestSkillsHandler_EmitCacheInvalidate_NilMsgBusNoop verifies the helper is
// safe with an unset msgBus.
func TestSkillsHandler_EmitCacheInvalidate_NilMsgBusNoop(t *testing.T) {
h := &SkillsHandler{msgBus: nil}
// Must not panic.
h.emitCacheInvalidate(bus.CacheKindSkills, "x", uuid.New())
}