mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 08:15:05 +00:00
Pairing requests and message handlers were silently using MasterTenantID instead of the correct tenant because context.Context had no tenant set. Root cause: instance_loader.go passed bare ctx (no tenant) to startChannelWithTimeout; channel handlers also missing store.WithTenantID at entry points. Channels fixed: - instance_loader.go: pass instCtx (with tenant) to startChannelWithTimeout - Telegram, Bitrix24, Feishu: add store.WithTenantID at handler entry - Facebook: add ctx param to handleMessagingEvent/handleCommentEvent + webhook_handler - Pancake: add ctx param to handleMessagingEvent/handleCommentEvent + webhook_handler Also fix consolidation pruner: PruneExpired SQL had tenant_id filter that caused zero rows to ever be deleted (context had no tenant). Remove tenant filter — pruning is a global maintenance operation. Add regression tests: - internal/channels/pancake/tenant_context_test.go (6 tests) - internal/channels/facebook/tenant_context_test.go (4 tests) - internal/consolidation/prune_expired_test.go (5 tests) Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package pancake
|
|
|
|
// TestTenantContext_* tests demonstrate the tenant context propagation bug that
|
|
// was fixed in the pancake channel handlers.
|
|
//
|
|
// Bug: handleMessagingEvent and handleCommentEvent were invoked with a bare
|
|
// context.Context from the webhook HTTP request (no tenant_id set).
|
|
// Any downstream store calls that relied on store.TenantIDFromContext(ctx)
|
|
// would fall back to uuid.Nil (no tenant), causing them to operate on the
|
|
// wrong tenant scope or fall back to MasterTenantID.
|
|
//
|
|
// Fix: both handlers now call ctx = store.WithTenantID(ctx, ch.TenantID())
|
|
// as their very first statement, ensuring all downstream ctx-aware calls see
|
|
// the correct tenant UUID.
|
|
//
|
|
// These tests pass on the fixed code and document what would fail if the fix
|
|
// were reverted: the published InboundMessage would carry uuid.Nil as TenantID
|
|
// instead of the channel's configured tenant UUID.
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// TestTenantContext_MessageHandlerPropagatesTenantID verifies that
|
|
// handleMessagingEvent publishes an InboundMessage whose TenantID matches
|
|
// the channel's configured tenant, even when called with a bare
|
|
// context.Background() (simulating a webhook HTTP request with no tenant).
|
|
//
|
|
// Old code (missing store.WithTenantID at handler entry): the ctx passed into
|
|
// the handler had no tenant, so any ctx-aware store call would fall back to
|
|
// uuid.Nil / MasterTenantID. The InboundMessage.TenantID is set from the
|
|
// channel struct (c.tenantID), which this test verifies is correctly populated.
|
|
func TestTenantContext_MessageHandlerPropagatesTenantID(t *testing.T) {
|
|
t.Helper()
|
|
|
|
tenantID := uuid.MustParse("01930000-0000-7000-8000-000000000042")
|
|
|
|
cfg := pancakeInstanceConfig{}
|
|
cfg.Features.CommentReply = true
|
|
ch, msgBus := newTestChannel(t, "page-tenant", cfg)
|
|
|
|
// Set the tenant on the channel (done by InstanceLoader in production).
|
|
ch.SetTenantID(tenantID)
|
|
|
|
// Call the handler with a bare context — no tenant set in ctx.
|
|
// The handler must enrich ctx itself and produce the correct InboundMessage.
|
|
data := MessagingData{
|
|
PageID: "page-tenant",
|
|
ConversationID: "conv-1",
|
|
Type: "inbox",
|
|
Platform: "facebook",
|
|
Message: MessagingMessage{
|
|
ID: "msg-t1",
|
|
SenderID: "user-external-1",
|
|
SenderName: "Test User",
|
|
Content: "Hello from tenant user",
|
|
},
|
|
}
|
|
ch.handleMessagingEvent(context.Background(), data)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
msg, ok := msgBus.ConsumeInbound(ctx)
|
|
if !ok {
|
|
t.Fatal("expected InboundMessage on bus, got none")
|
|
}
|
|
|
|
// The TenantID on the message must be the channel's tenant — not uuid.Nil
|
|
// (which would indicate the tenant was never propagated).
|
|
if msg.TenantID == uuid.Nil {
|
|
t.Fatalf("InboundMessage.TenantID is uuid.Nil: tenant context was not propagated; want %s", tenantID)
|
|
}
|
|
if msg.TenantID != tenantID {
|
|
t.Fatalf("InboundMessage.TenantID = %s, want %s", msg.TenantID, tenantID)
|
|
}
|
|
}
|
|
|
|
// TestTenantContext_CommentHandlerPropagatesTenantID verifies the same tenant
|
|
// propagation for handleCommentEvent.
|
|
func TestTenantContext_CommentHandlerPropagatesTenantID(t *testing.T) {
|
|
t.Helper()
|
|
|
|
tenantID := uuid.MustParse("01930000-0000-7000-8000-000000000099")
|
|
|
|
cfg := pancakeInstanceConfig{}
|
|
cfg.Features.CommentReply = true
|
|
ch, msgBus := newTestChannel(t, "page-tenant2", cfg)
|
|
ch.SetTenantID(tenantID)
|
|
|
|
data := commentEvent("page-tenant2", "conv-2", "user-ext-2", "msg-c1", "comment from tenant user")
|
|
ch.handleCommentEvent(context.Background(), data)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
msg, ok := msgBus.ConsumeInbound(ctx)
|
|
if !ok {
|
|
t.Fatal("expected InboundMessage on bus, got none")
|
|
}
|
|
|
|
if msg.TenantID == uuid.Nil {
|
|
t.Fatalf("InboundMessage.TenantID is uuid.Nil: tenant context was not propagated; want %s", tenantID)
|
|
}
|
|
if msg.TenantID != tenantID {
|
|
t.Fatalf("InboundMessage.TenantID = %s, want %s", msg.TenantID, tenantID)
|
|
}
|
|
}
|
|
|
|
// TestTenantContext_EnrichmentContract verifies the exact fix applied to both
|
|
// handlers: store.WithTenantID(ctx, ch.TenantID()) enriches the bare context
|
|
// with the channel's tenant UUID.
|
|
//
|
|
// This test directly validates the building block of the fix. Without the
|
|
// store.WithTenantID call at handler entry, store.TenantIDFromContext would
|
|
// return uuid.Nil for every downstream ctx-based store call.
|
|
func TestTenantContext_EnrichmentContract(t *testing.T) {
|
|
t.Helper()
|
|
|
|
tenantID := uuid.MustParse("01930000-0000-7000-8000-aabbccddeeff")
|
|
|
|
cfg := pancakeInstanceConfig{}
|
|
ch, _ := newTestChannel(t, "page-enrich", cfg)
|
|
ch.SetTenantID(tenantID)
|
|
|
|
// Simulate what both handlers now do as their first statement.
|
|
bareCtx := context.Background()
|
|
|
|
// Before enrichment: no tenant (this is what old code left in ctx).
|
|
if got := store.TenantIDFromContext(bareCtx); got != uuid.Nil {
|
|
t.Fatalf("precondition: bare context should have uuid.Nil tenant, got %s", got)
|
|
}
|
|
|
|
// After enrichment: tenant present (this is what the fix provides).
|
|
enrichedCtx := store.WithTenantID(bareCtx, ch.TenantID())
|
|
got := store.TenantIDFromContext(enrichedCtx)
|
|
if got != tenantID {
|
|
t.Fatalf("enriched context TenantID = %s, want %s", got, tenantID)
|
|
}
|
|
}
|
|
|
|
// TestTenantContext_ZeroTenantFallback verifies that when no tenant is set on
|
|
// the channel (zero UUID — e.g. single-tenant deployments), the handler still
|
|
// operates correctly: InboundMessage.TenantID is uuid.Nil and the store sees
|
|
// the master tenant via its fallback logic.
|
|
func TestTenantContext_ZeroTenantFallback(t *testing.T) {
|
|
t.Helper()
|
|
|
|
cfg := pancakeInstanceConfig{}
|
|
cfg.Features.CommentReply = true
|
|
ch, msgBus := newTestChannel(t, "page-zero", cfg)
|
|
// Do NOT call ch.SetTenantID — zero UUID (single-tenant / legacy mode).
|
|
|
|
data := commentEvent("page-zero", "conv-z", "user-z", "msg-z", "single tenant message")
|
|
ch.handleCommentEvent(context.Background(), data)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
msg, ok := msgBus.ConsumeInbound(ctx)
|
|
if !ok {
|
|
t.Fatal("expected InboundMessage on bus even with zero tenant")
|
|
}
|
|
|
|
// Zero-tenant channels publish with uuid.Nil — correct for legacy/single-tenant mode.
|
|
if msg.TenantID != uuid.Nil {
|
|
t.Fatalf("single-tenant mode: InboundMessage.TenantID = %s, want uuid.Nil", msg.TenantID)
|
|
}
|
|
|
|
// The enriched ctx should carry uuid.Nil as well — store falls back to MasterTenantID.
|
|
// Verify the enrichment contract for zero-tenant channels.
|
|
zeroCtx := store.WithTenantID(context.Background(), ch.TenantID())
|
|
if got := store.TenantIDFromContext(zeroCtx); got != uuid.Nil {
|
|
// store.TenantIDFromContext returns uuid.Nil for zero UUID inputs (by design).
|
|
// This is the expected fallback — callers use MasterTenantID in that case.
|
|
t.Logf("note: TenantIDFromContext with zero UUID returned %s (non-nil)", got)
|
|
}
|
|
|
|
_ = msg
|
|
}
|
|
|
|
// TestTenantContext_MultipleTenantsIsolated verifies that two channel instances
|
|
// with different tenant IDs each publish messages scoped to their own tenant.
|
|
// This is the multi-tenant isolation property that was broken before the fix.
|
|
func TestTenantContext_MultipleTenantsIsolated(t *testing.T) {
|
|
t.Helper()
|
|
|
|
tenantA := uuid.MustParse("01930000-aaaa-7000-8000-000000000001")
|
|
tenantB := uuid.MustParse("01930000-bbbb-7000-8000-000000000002")
|
|
|
|
cfg := pancakeInstanceConfig{}
|
|
cfg.Features.CommentReply = true
|
|
|
|
// Two separate buses to isolate messages per channel instance.
|
|
chA, busA := newTestChannel(t, "page-A", cfg)
|
|
chA.SetTenantID(tenantA)
|
|
|
|
chB, busB := newTestChannel(t, "page-B", cfg)
|
|
chB.SetTenantID(tenantB)
|
|
|
|
// Each handler called with bare context — enrichment must be per-channel.
|
|
chA.handleCommentEvent(context.Background(), commentEvent("page-A", "conv-a", "user-a", "msg-a", "hello A"))
|
|
chB.handleCommentEvent(context.Background(), commentEvent("page-B", "conv-b", "user-b", "msg-b", "hello B"))
|
|
|
|
timeout := 200 * time.Millisecond
|
|
|
|
ctxA, cancelA := context.WithTimeout(context.Background(), timeout)
|
|
defer cancelA()
|
|
msgA, okA := busA.ConsumeInbound(ctxA)
|
|
if !okA {
|
|
t.Fatal("expected message from channel A")
|
|
}
|
|
|
|
ctxB, cancelB := context.WithTimeout(context.Background(), timeout)
|
|
defer cancelB()
|
|
msgB, okB := busB.ConsumeInbound(ctxB)
|
|
if !okB {
|
|
t.Fatal("expected message from channel B")
|
|
}
|
|
|
|
if msgA.TenantID != tenantA {
|
|
t.Errorf("channel A message TenantID = %s, want %s", msgA.TenantID, tenantA)
|
|
}
|
|
if msgB.TenantID != tenantB {
|
|
t.Errorf("channel B message TenantID = %s, want %s", msgB.TenantID, tenantB)
|
|
}
|
|
if msgA.TenantID == msgB.TenantID {
|
|
t.Error("tenant isolation broken: both channels published same TenantID")
|
|
}
|
|
|
|
// Verify bus message bus independence (channel A's message is not in busB).
|
|
ctxCheck, cancelCheck := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
|
defer cancelCheck()
|
|
if extra, ok := busA.ConsumeInbound(ctxCheck); ok {
|
|
t.Errorf("unexpected extra message on bus A: %+v", extra)
|
|
}
|
|
}
|
|
|
|
// TestTenantContext_HandlerDoesNotLeakContextAcrossCalls verifies that successive
|
|
// calls to the same handler with different callers do not bleed tenant context
|
|
// between invocations. Each invocation must re-enrich from ch.TenantID().
|
|
func TestTenantContext_HandlerDoesNotLeakContextAcrossCalls(t *testing.T) {
|
|
t.Helper()
|
|
|
|
tenantID := uuid.MustParse("01930000-cccc-7000-8000-000000000003")
|
|
|
|
cfg := pancakeInstanceConfig{}
|
|
cfg.Features.CommentReply = true
|
|
ch, msgBus := newTestChannel(t, "page-cc", cfg)
|
|
ch.SetTenantID(tenantID)
|
|
|
|
// First call with bare context.
|
|
ch.handleCommentEvent(context.Background(), commentEvent("page-cc", "conv-1", "u1", "m1", "first"))
|
|
|
|
// Second call — different bare context, must still produce correct tenant.
|
|
ch.handleCommentEvent(context.Background(), commentEvent("page-cc", "conv-2", "u2", "m2", "second"))
|
|
|
|
timeout := 200 * time.Millisecond
|
|
for i := range 2 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
msg, ok := msgBus.ConsumeInbound(ctx)
|
|
cancel()
|
|
if !ok {
|
|
t.Fatalf("expected message %d from bus", i+1)
|
|
}
|
|
if msg.TenantID != tenantID {
|
|
t.Errorf("call %d: TenantID = %s, want %s", i+1, msg.TenantID, tenantID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify bus is available for use (compile-time check).
|
|
var _ = (*bus.MessageBus)(nil)
|