fix(broadcast): resolve true org root in CTE, not sender id (#1959) #2105

Merged
devops-engineer merged 1 commits from fix/broadcast-org-root-cte-1959 into main 2026-06-01 19:12:28 +00:00
2 changed files with 147 additions and 3 deletions
@@ -85,15 +85,15 @@ func (h *BroadcastHandler) Broadcast(c *gin.Context) {
var orgRootID string
err = db.DB.QueryRowContext(ctx, `
WITH RECURSIVE org_chain AS (
SELECT id, parent_id, id AS root_id
SELECT id, parent_id
FROM workspaces
WHERE id = $1
UNION ALL
SELECT w.id, w.parent_id, c.root_id
SELECT w.id, w.parent_id
FROM workspaces w
JOIN org_chain c ON w.id = c.parent_id
)
SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
SELECT id AS root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
`, senderID).Scan(&orgRootID)
if err != nil {
log.Printf("Broadcast: org root lookup for %s: %v", senderID, err)
@@ -0,0 +1,144 @@
//go:build integration
// +build integration
// workspace_broadcast_org_root_integration_test.go — REAL Postgres
// regression test for #1959: the Broadcast org-root recursive CTE.
//
// Run with:
//
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
// go test -tags=integration ./internal/handlers/ -run Integration_BroadcastOrgRoot -v
//
// CI: piggybacks on .github/workflows/handlers-postgres-integration.yml
// (path-filter includes workspace-server/internal/handlers/**).
//
// Why this is NOT a sqlmock test
// ------------------------------
// The unit tests in workspace_broadcast_test.go use sqlmock, which
// returns whatever rows the test stubs — it CANNOT execute the
// recursive CTE, so it cannot catch the #1959 bug where the anchor
// pinned `id AS root_id` to the SENDER's own id and carried it
// unchanged up the chain. With that bug a non-root sender resolved
// ITSELF as the org root (wrong broadcast scoping). Only a real
// Postgres can prove the corrected CTE resolves UP to the true
// null-parent ancestor.
//
// The query under test is copied verbatim from Broadcast() in
// workspace_broadcast.go; if that query changes, this test must be
// updated in lockstep (it is the real-artifact gate for the fix).
package handlers
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
// orgRootCTE is the exact org-root resolution query from Broadcast().
// Kept here verbatim so the test fails loudly if the handler regresses
// to the #1959 sender-id-pinned form.
const orgRootCTE = `
WITH RECURSIVE org_chain AS (
SELECT id, parent_id
FROM workspaces
WHERE id = $1
UNION ALL
SELECT w.id, w.parent_id
FROM workspaces w
JOIN org_chain c ON w.id = c.parent_id
)
SELECT id AS root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
`
func integrationDB_BroadcastOrgRoot(t *testing.T) *sql.DB {
t.Helper()
url := os.Getenv("INTEGRATION_DB_URL")
if url == "" {
t.Skip("INTEGRATION_DB_URL not set; skipping (see file header)")
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
// TestIntegration_BroadcastOrgRoot_NonRootSenderResolvesToRoot builds a
// real three-level org chain in Postgres:
//
// root (parent_id = NULL)
// └── mid (parent_id = root)
// └── leaf (parent_id = mid) ← non-root sender
//
// and runs the handler's org-root CTE for each node. Every node — root,
// mid, and leaf — MUST resolve to `root`. Under the #1959 bug the leaf
// (and mid) resolved to themselves; this test pins the fix.
func TestIntegration_BroadcastOrgRoot_NonRootSenderResolvesToRoot(t *testing.T) {
conn := integrationDB_BroadcastOrgRoot(t)
ctx := context.Background()
prefix := fmt.Sprintf("itest-bcastroot-%s", uuid.New().String()[:8])
t.Cleanup(func() {
if _, err := conn.ExecContext(ctx,
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
t.Logf("cleanup (non-fatal): %v", err)
}
})
rootID := uuid.New().String()
midID := uuid.New().String()
leafID := uuid.New().String()
// root — parent_id NULL.
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
VALUES ($1, $2, 2, 'claude-code', 'running', NULL)
`, rootID, prefix+"-root"); err != nil {
t.Fatalf("seed root: %v", err)
}
// mid — child of root.
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
VALUES ($1, $2, 2, 'claude-code', 'running', $3)
`, midID, prefix+"-mid", rootID); err != nil {
t.Fatalf("seed mid: %v", err)
}
// leaf — child of mid (a non-root, non-direct-child sender).
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
VALUES ($1, $2, 2, 'claude-code', 'running', $3)
`, leafID, prefix+"-leaf", midID); err != nil {
t.Fatalf("seed leaf: %v", err)
}
cases := []struct {
name string
senderID string
}{
{"root sender resolves to itself", rootID},
{"mid sender resolves to root", midID},
{"leaf (deep non-root) sender resolves to root", leafID},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got string
if err := conn.QueryRowContext(ctx, orgRootCTE, tc.senderID).Scan(&got); err != nil {
t.Fatalf("org-root CTE for %s: %v", tc.senderID, err)
}
if got != rootID {
t.Errorf("org root for sender %s = %s; want %s (the true null-parent ancestor) — #1959 regression: a non-root sender resolved to the wrong root",
tc.senderID, got, rootID)
}
})
}
}