Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions tests/retrieval-owner-filter-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

// Guard for the retrieval owner-scope boundary (48h-review finding #3).
//
// The SQL `retrieval_owner_matches(owner_filter, row_owner_id)` returns TRUE for EVERY row when
// `owner_filter IS NULL` (fails open by design; RLS is service-role-only, so the app filter is the
// sole tenant boundary). Until a migration makes null fail closed — gated on the Supabase Preview
// drift fix so the SQL change can be verified — this test is the tripwire: no `.rpc(...)` call in
// `src/` may pass a *literal* null/undefined `owner_filter`, and every owner_filter value must come
// from the sanctioned scope helpers (which fail closed in production) or the public sentinel — never
// a raw/unaudited value that could reach the RPC as null and silently return another tenant's rows.

const SRC_DIR = join(process.cwd(), "src");

// Sanctioned right-hand sides for an `owner_filter` / `p_owner_filter` RPC argument.
const SANCTIONED_SOURCES = [
/^retrievalOwnerFilter\(/,
/^requireOwnerScope\(/,
/^ownerScopeForDocumentFilteredRetrieval\(/,
/^PUBLIC_OWNER_FILTER_SENTINEL\b/,
// corpus-grounding threads through the exact scope it was handed; documented safe because rag.ts
// derives `args.ownerFilter` from ownerScopeForDocumentFilteredRetrieval (never a raw null in prod).
/^args\.ownerFilter\b/,
];

const OWNER_FILTER_ARG = /\b(?:p_)?owner_filter\s*:\s*(.+?)\s*,?\s*$/;

function sourceFiles(dir: string): string[] {
// `recursive: true` without `withFileTypes` returns relative paths as strings — avoids the
// Dirent.parentPath/path typing churn across @types/node versions.
return readdirSync(dir, { recursive: true })
.map((entry) => String(entry))
.filter((name) => /\.tsx?$/.test(name) && !name.endsWith(".d.ts") && !name.includes("database.types"))
.map((name) => join(dir, name));
}

describe("retrieval owner_filter callsite guard (finding #3)", () => {
it("routes every owner_filter RPC argument through a sanctioned scope source (never literal null)", () => {
const offenders: string[] = [];
let checked = 0;

for (const file of sourceFiles(SRC_DIR)) {
const lines = readFileSync(file, "utf8").split(/\r?\n/);
lines.forEach((line, index) => {
const trimmed = line.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return;
const match = trimmed.match(OWNER_FILTER_ARG);
if (!match) return;

checked += 1;
const rhs = match[1].trim();
const location = `${file.replace(process.cwd(), "").replace(/\\/g, "/")}:${index + 1}`;
if (/^(null|undefined)\b/.test(rhs)) {
offenders.push(`${location} — literal ${rhs}`);
} else if (!SANCTIONED_SOURCES.some((pattern) => pattern.test(rhs))) {
offenders.push(`${location} — unsanctioned owner_filter source: ${rhs}`);
}
});
}

// Fail loudly if the scan matched nothing (e.g. the RPC param was renamed) rather than passing vacuously.
expect(
checked,
"found no owner_filter RPC callsites to guard — has the param name changed?",
).toBeGreaterThanOrEqual(5);
expect(
offenders,
`owner_filter must come from retrievalOwnerFilter / requireOwnerScope / ` +
`ownerScopeForDocumentFilteredRetrieval / PUBLIC_OWNER_FILTER_SENTINEL — never a raw or null value:\n` +
offenders.join("\n"),
).toEqual([]);
});
});
13 changes: 11 additions & 2 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,13 +762,22 @@ test.describe("Clinical KB tools launcher", () => {
await expect(dock).toBeVisible();
await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");

await page.evaluate(() => window.scrollTo({ top: 120, behavior: "auto" }));
await page.evaluate(() => {
window.scrollTo({ top: 120, behavior: "auto" });
// WebKit doesn't reliably emit a native scroll event for a programmatic scrollTo, so the
// hide-on-scroll listener never ran and data-scroll-hidden stayed unset (release-browser-matrix
// WebKit flake). Dispatch one explicitly; harmless on Chromium/Firefox (the rAF guard dedupes).
window.dispatchEvent(new Event("scroll"));
});
await expect(dock).toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform !== "none"))
.toBe(true);

await page.evaluate(() => window.scrollTo({ top: 60, behavior: "auto" }));
await page.evaluate(() => {
window.scrollTo({ top: 60, behavior: "auto" });
window.dispatchEvent(new Event("scroll"));
});
await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform === "none"))
Expand Down
Loading