import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { MIGRATIONS_FOLDER } from "../db/fixtures"; import { projectRoots, projects, timelineEvents } from "../db/schema"; import type { CapabilityContext } from "./context"; import { buildTimelineScope } from "./scope"; // buildTimelineScope is the filter that keeps timeline queries scoped to an // org's devices and projects. The security-critical contract: whenever an // organization is present, it MUST return a filter (never undefined / "no // filter"), including the empty-devices case — otherwise a query would return // every org's data. let sqlite: Database; let db: CapabilityContext["db"]; beforeEach(() => { sqlite = new Database(":memory:"); db = drizzle({ client: sqlite }); migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); }); afterEach(() => { sqlite.close(); }); function ctx(partial: Partial): CapabilityContext { return { db, userId: "u1", ...partial } as CapabilityContext; } describe("buildTimelineScope", () => { test("no organization → undefined (local mode, no scoping)", () => { expect(buildTimelineScope(ctx({ organizationId: null }))).toBeUndefined(); expect( buildTimelineScope(ctx({ organizationId: undefined })), ).toBeUndefined(); }); test("org + missing deviceIds → throws (fail closed, never unscoped)", () => { // Previously fell through to `undefined` (no filter) — a latent // cross-tenant fail-open. An org context with no resolved device list // must refuse to run rather than silently return every org's data. expect(() => buildTimelineScope(ctx({ organizationId: "org1", deviceIds: undefined })), ).toThrow(/refusing to run unscoped/); expect(() => buildTimelineScope(ctx({ organizationId: "org1", deviceIds: null })), ).toThrow(/refusing to run unscoped/); }); test("org + EMPTY deviceIds → an org-project filter, never undefined", () => { const result = buildTimelineScope( ctx({ organizationId: "org1", deviceIds: [] }), ); expect(result).toBeDefined(); }); test("org + deviceIds → a filter (scoping applied)", () => { const result = buildTimelineScope( ctx({ organizationId: "org1", deviceIds: ["d1", "d2"] }), ); expect(result).toBeDefined(); }); test("empty-devices and populated-devices both produce filters", () => { // Both org cases are scoped; only the no-org / no-deviceIds cases are // unscoped. This locks the distinction that prevents cross-org leakage. const empty = buildTimelineScope( ctx({ organizationId: "org1", deviceIds: [] }), ); const populated = buildTimelineScope( ctx({ organizationId: "org1", deviceIds: ["d1"] }), ); expect(empty).toBeDefined(); expect(populated).toBeDefined(); }); test("device-less events include only project roots owned by the org", async () => { await db.insert(projects).values([ { id: "project-org1", name: "Org 1", organizationId: "org1" }, { id: "project-org2", name: "Org 2", organizationId: "org2" }, ]); await db.insert(projectRoots).values([ { id: "root-org1", projectId: "project-org1", path: "/org1" }, { id: "root-org2", projectId: "project-org2", path: "/org2" }, ]); await db.insert(timelineEvents).values([ { id: "event-org1", timestamp: new Date("2026-07-22T12:00:00Z"), source: "git", projectRootId: "root-org1", deviceId: null, }, { id: "event-org2", timestamp: new Date("2026-07-22T12:00:00Z"), source: "git", projectRootId: "root-org2", deviceId: null, }, ]); const scope = buildTimelineScope( ctx({ organizationId: "org1", deviceIds: [] }), ); const rows = await db .select({ id: timelineEvents.id }) .from(timelineEvents) .where(scope); expect(rows).toEqual([{ id: "event-org1" }]); }); });