178 lines
7.3 KiB
TypeScript
178 lines
7.3 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
checkTenantIsolationReport,
|
|
collectTenantIsolationEvidence,
|
|
discoverIsolationTestPaths,
|
|
renderTenantIsolationReport,
|
|
REPORT_PATH,
|
|
runTenantIsolationReportCli,
|
|
S04_SUMMARY_PATH,
|
|
writeTenantIsolationReport,
|
|
} from "./tenant-isolation-report";
|
|
|
|
let tempRoot: string;
|
|
|
|
function writeFixture(relativePath: string, content: string): void {
|
|
const absolutePath = path.join(tempRoot, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content, "utf8");
|
|
}
|
|
|
|
function seedIsolationFixtures(): void {
|
|
writeFixture(
|
|
S04_SUMMARY_PATH,
|
|
`---
|
|
verification_result: passed with apiKey: "s04-api-key-should-not-leak"
|
|
patterns_established:
|
|
- tenant_a_session keeps row_iso_secret_001 inside the resolver tenant boundary.
|
|
- relationship_iso_parent_001 and relationship_iso_child_001 require parent and child authorization.
|
|
---
|
|
|
|
**S04 summary for tenant_a_session@example.com and token = "s04-token-secret-value";**
|
|
`,
|
|
);
|
|
|
|
writeFixture(
|
|
"apps/web/app/test/isolation/admin-routes.test.ts",
|
|
`it("admin permission denies tenant_admin_secret@example.com bearer_token_secret_001", () => {
|
|
const tenantId = "tenant_iso_alpha";
|
|
const token = "admin-token-secret-value";
|
|
expect(permission).toEqual("denied");
|
|
});
|
|
`,
|
|
);
|
|
|
|
writeFixture(
|
|
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
|
|
`test("returns 403 for foreign tenant row_iso_999 from tenant_b", () => {
|
|
const userId = "user_iso_beta";
|
|
const apiKey = "api_key_entity_secret_001";
|
|
expect(status).toBe(403);
|
|
});
|
|
it("skips relationship resolver for relationship_iso_parent_abc child relationship_iso_child_def", () => {
|
|
const session = "session_iso_gamma";
|
|
const logId = "log_iso_delta";
|
|
expect(repository.update).not.toHaveBeenCalled();
|
|
});
|
|
`,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tenant-isolation-report-"));
|
|
seedIsolationFixtures();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(tempRoot, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("tenant isolation report renderer", () => {
|
|
it("renders deterministic report sections and evidence labels", () => {
|
|
const evidence = collectTenantIsolationEvidence(tempRoot);
|
|
const firstReport = renderTenantIsolationReport(evidence);
|
|
const secondReport = renderTenantIsolationReport(collectTenantIsolationEvidence(tempRoot));
|
|
|
|
expect(secondReport).toBe(firstReport);
|
|
expect(firstReport).toContain("## Scope");
|
|
expect(firstReport).toContain("## Source Evidence");
|
|
expect(firstReport).toContain("## Boundary Matrix");
|
|
expect(firstReport).toContain("| Source | Test count | Checked access boundaries | Deny-path evidence |");
|
|
expect(firstReport).toContain("## Prior Slice Evidence");
|
|
expect(firstReport).toContain("## Deny-Path Coverage");
|
|
expect(firstReport).toContain("## Redaction Rules");
|
|
expect(firstReport).toContain("## Verification Commands");
|
|
expect(firstReport).toContain("## Redaction Contract");
|
|
expect(firstReport).toContain("admin permission gate");
|
|
expect(firstReport).toContain("tenant and user scoped resolver");
|
|
expect(firstReport).toContain("relationship parent/child authorization");
|
|
expect(firstReport).toContain("403 response for unauthorized tenant access");
|
|
expect(firstReport).toContain("denied path skips data mutation or lookup");
|
|
expect(firstReport).toContain("pnpm exec vitest run scripts/tenant-isolation-report.test.ts");
|
|
expect(firstReport).toContain("pnpm exec tsx scripts/tenant-isolation-report.ts --check");
|
|
expect(firstReport).toContain("pnpm exec tsx scripts/docs-link-check.ts");
|
|
});
|
|
|
|
it("redacts concrete fixture identifiers, credentials, and source snippets while keeping categories", () => {
|
|
const report = renderTenantIsolationReport(collectTenantIsolationEvidence(tempRoot));
|
|
|
|
expect(report).toContain("tenant A");
|
|
expect(report).toContain("tenant B");
|
|
expect(report).toContain("API credential");
|
|
expect(report).toContain("row record");
|
|
expect(report).toContain("relationship record");
|
|
expect(report).toContain("user session");
|
|
|
|
expect(report).not.toContain("tenant_admin_secret@example.com");
|
|
expect(report).not.toContain("tenant_a_session@example.com");
|
|
expect(report).not.toContain("tenant_iso_alpha");
|
|
expect(report).not.toContain("tenant_b");
|
|
expect(report).not.toContain("row_iso_999");
|
|
expect(report).not.toContain("relationship_iso_parent_abc");
|
|
expect(report).not.toContain("relationship_iso_child_def");
|
|
expect(report).not.toContain("user_iso_beta");
|
|
expect(report).not.toContain("session_iso_gamma");
|
|
expect(report).not.toContain("log_iso_delta");
|
|
expect(report).not.toContain("admin-token-secret-value");
|
|
expect(report).not.toContain("api_key_entity_secret_001");
|
|
expect(report).not.toContain("s04-api-key-should-not-leak");
|
|
expect(report).not.toContain("s04-token-secret-value");
|
|
expect(report).not.toContain("expect(repository.update).not.toHaveBeenCalled();");
|
|
expect(report).not.toContain("const tenantId");
|
|
});
|
|
|
|
it("reports missing evidence files with concise diagnostics", () => {
|
|
fs.rmSync(path.join(tempRoot, "apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts"));
|
|
|
|
expect(() => collectTenantIsolationEvidence(tempRoot)).toThrow(
|
|
"missing expected isolation evidence file: apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
|
|
);
|
|
});
|
|
|
|
it("discovers new isolation tests under curated isolation roots", () => {
|
|
writeFixture(
|
|
"apps/api/src/isolation/api-key-entity-rows.test.ts",
|
|
`it("returns 403 for cross-tenant API-key row access", () => {
|
|
expect(status).toBe(403);
|
|
});
|
|
`,
|
|
);
|
|
|
|
expect(discoverIsolationTestPaths(tempRoot)).toEqual([
|
|
"apps/api/src/isolation/api-key-entity-rows.test.ts",
|
|
"apps/web/app/test/isolation/admin-routes.test.ts",
|
|
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
|
|
]);
|
|
|
|
const evidence = collectTenantIsolationEvidence(tempRoot);
|
|
expect(evidence.generatedFrom).toContain("apps/api/src/isolation/api-key-entity-rows.test.ts");
|
|
});
|
|
|
|
it("detects stale generated output in check mode without leaking fixture content", () => {
|
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
|
|
writeTenantIsolationReport(tempRoot);
|
|
fs.writeFileSync(path.join(tempRoot, REPORT_PATH), "# stale report with tenant_iso_alpha and api_key_entity_secret_001\n", "utf8");
|
|
|
|
expect(checkTenantIsolationReport(tempRoot)).toEqual({
|
|
ok: false,
|
|
reportPath: path.join(tempRoot, REPORT_PATH),
|
|
failures: [`stale generated report: ${REPORT_PATH}`],
|
|
});
|
|
expect(runTenantIsolationReportCli(["--root=" + tempRoot, "--check"])).toBe(1);
|
|
|
|
const stderrOutput = stderrWrite.mock.calls.map((call) => String(call[0])).join("");
|
|
expect(stderrOutput).toContain(`FAIL stale generated report: ${REPORT_PATH}`);
|
|
expect(stderrOutput).not.toContain("tenant_iso_alpha");
|
|
expect(stderrOutput).not.toContain("api_key_entity_secret_001");
|
|
expect(stdoutWrite).not.toHaveBeenCalled();
|
|
});
|
|
});
|