Files
0pt/tests/analytics/duckdb-provider.test.ts
T

327 lines
13 KiB
TypeScript

/**
* tests/analytics/duckdb-provider.test.ts
* ========================================
* BI-P014 — the parts provable without a DuckDB, plus the pin on the parts
* that are not.
*
* The live suite (tests/duckdb/duckdb-provider.live.test.ts) only
* runs when DUCKDB_PYTHON is set. On a runner without it, it contributes
* nothing — so the recorded evidence is committed and THIS file, which never
* skips, checks it. In particular it pins the finding that makes layer 2 real:
*
* `allowed_directories` WITHOUT `enable_external_access=false` permits
* /etc/passwd. Measured, recorded, and asserted here — because a comment
* saying so would rot the moment someone "simplified" the bootstrap.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { SiaxError } from '@/src/platform/errors';
import {
ArtifactMountRegistry,
assertSessionSandboxed,
duckDbSessionBootstrap,
escapeLiteral,
DuckDbAnalyticsProvider,
REQUIRED_SANDBOX_SETTINGS,
type DuckDbRunner,
} from '@/src/analytics/providers/duckdb-provider';
const EVIDENCE_PATH = path.join(process.cwd(), 'evidence/bi-p014/duckdb-sandbox.json');
const FIXTURES = path.join(process.cwd(), 'tests/fixtures/duckdb');
const IDENTITY = { tenant_id: 't_alpha', principal_id: 'u_1' };
const capturingRunner = (): DuckDbRunner & { lastSql?: string; lastBootstrap?: readonly string[] } => {
const r: DuckDbRunner & { lastSql?: string; lastBootstrap?: readonly string[] } = {
engine: 'duckdb',
async run({ bootstrap, sql }) {
r.lastSql = sql;
r.lastBootstrap = bootstrap;
return [];
},
};
return r;
};
describe('BI-P014 — the session bootstrap is one unit, not three settings', () => {
it('emits all three statements, external access LAST-but-one and the lock last', () => {
const stmts = duckDbSessionBootstrap(['/mnt/artifacts']);
expect(stmts).toHaveLength(3);
expect(stmts[0]).toContain('allowed_directories');
expect(stmts[1]).toBe('SET enable_external_access=false');
expect(stmts[2]).toBe('SET lock_configuration=true');
});
it('refuses an EMPTY allow-list — that is not a sandbox', () => {
expect(() => duckDbSessionBootstrap([])).toThrowError(SiaxError);
});
it('refuses a relative or quote-carrying directory', () => {
expect(() => duckDbSessionBootstrap(['relative/path'])).toThrowError(SiaxError);
expect(() => duckDbSessionBootstrap(["/mnt/it's"])).toThrowError(SiaxError);
});
it.each([...REQUIRED_SANDBOX_SETTINGS])(
'assertSessionSandboxed refuses a session missing %s',
(setting) => {
const stmts = duckDbSessionBootstrap(['/mnt/artifacts']).filter((s) => !s.includes(setting));
expect(() => assertSessionSandboxed(stmts)).toThrowError(SiaxError);
},
);
it('refuses a session that sets the allow-list but leaves external access ON', () => {
// The exact naive spelling. It looks configured and permits everything.
const naive = [
"SET allowed_directories=['/mnt/artifacts']",
'SET enable_external_access=true',
'SET lock_configuration=true',
];
expect(() => assertSessionSandboxed(naive)).toThrowError(SiaxError);
});
});
describe('BI-P014 — the mount registry refuses everything that is not a mounted artifact', () => {
function registry(): ArtifactMountRegistry {
// A fake realpath, so path semantics are tested without touching disk.
const fakeReal = (p: string) => (p.includes('symlinked') ? '/etc/passwd' : p);
const reg = new ArtifactMountRegistry('/mnt/artifacts', fakeReal);
reg.register({
artifact_id: 'a_ok',
tenant_id: 't_alpha',
st0re_ref: 'st0re://x',
relative_path: 'sales.parquet',
format: 'parquet',
size_bytes: 10,
});
return reg;
}
it('resolves a mounted artifact for its owner', () => {
expect(registry().resolve(IDENTITY, 'a_ok').absolutePath).toBe('/mnt/artifacts/sales.parquet');
});
it('refuses an absolute mount path', () => {
expect(() =>
registry().register({
artifact_id: 'a_abs',
tenant_id: 't_alpha',
st0re_ref: 'x',
relative_path: '/etc/passwd',
format: 'csv',
size_bytes: 1,
}),
).toThrowError(SiaxError);
});
it('refuses a path that resolves outside the root once symlinks are followed', () => {
expect(() =>
registry().register({
artifact_id: 'a_link',
tenant_id: 't_alpha',
st0re_ref: 'x',
relative_path: 'symlinked.parquet',
format: 'parquet',
size_bytes: 1,
}),
).toThrowError(SiaxError);
});
it("refuses a sibling directory that merely shares the root's prefix", () => {
// `startsWith('/mnt/artifacts')` alone would accept `/mnt/artifacts-evil`.
const reg = new ArtifactMountRegistry('/mnt/artifacts', (p) =>
p.includes('sibling') ? '/mnt/artifacts-evil/x.parquet' : p,
);
expect(() =>
reg.register({
artifact_id: 'a_sib',
tenant_id: 't_alpha',
st0re_ref: 'x',
relative_path: 'sibling.parquet',
format: 'parquet',
size_bytes: 1,
}),
).toThrowError(SiaxError);
});
it('refuses a request with no verified tenant', () => {
expect(() => registry().resolve({ tenant_id: '', principal_id: 'u' }, 'a_ok')).toThrowError(SiaxError);
});
it('distinguishes NOT_FOUND from AUTHORIZATION_DENIED', () => {
const reg = registry();
try {
reg.resolve(IDENTITY, 'a_missing');
} catch (e) {
expect((e as SiaxError).code).toBe('NOT_FOUND');
}
try {
reg.resolve({ tenant_id: 't_beta', principal_id: 'u' }, 'a_ok');
} catch (e) {
// An attempted cross-tenant read must be visible in the audit trail as
// one, not disguised as a missing object.
expect((e as SiaxError).code).toBe('AUTHORIZATION_DENIED');
}
});
it('refuses an artifact id that is not registry-safe', () => {
for (const bad of ['../x', 'a b', "a'b", 'a/b', '']) {
expect(() => registry().resolve(IDENTITY, bad)).toThrowError(SiaxError);
}
});
});
describe('BI-P014 — the query builder has no caller-SQL surface', () => {
function provider(runner: DuckDbRunner, sizeBytes = 10) {
const reg = new ArtifactMountRegistry('/mnt/artifacts', (p) => p);
reg.register({
artifact_id: 'a_ok',
tenant_id: 't_alpha',
st0re_ref: 'x',
relative_path: 'sales.parquet',
format: 'parquet',
size_bytes: sizeBytes,
});
return new DuckDbAnalyticsProvider(reg, runner);
}
it('builds a reader from the RESOLVED path, never from caller text', async () => {
const r = capturingRunner();
await provider(r).query(IDENTITY, { artifact_id: 'a_ok', select: ['country'] });
expect(r.lastSql).toContain("read_parquet('/mnt/artifacts/sales.parquet')");
});
it('always applies the sandbox bootstrap before the query', async () => {
const r = capturingRunner();
await provider(r).query(IDENTITY, { artifact_id: 'a_ok' });
expect(() => assertSessionSandboxed(r.lastBootstrap ?? [])).not.toThrow();
});
it.each(['a b', 'x; DROP TABLE t', '*', '1=1', "a'", '../x'])(
'refuses %s as a column name',
async (col) => {
await expect(
provider(capturingRunner()).query(IDENTITY, { artifact_id: 'a_ok', select: [col] }),
).rejects.toBeInstanceOf(SiaxError);
},
);
it('hands a heavy artifact to N0D instead of running it locally', async () => {
let thrown: unknown;
try {
await provider(capturingRunner(), 40 * 1024 * 1024 * 1024).query(IDENTITY, { artifact_id: 'a_ok' });
} catch (e) {
thrown = e;
}
expect((thrown as SiaxError).code).toBe('CAPABILITY_UNAVAILABLE');
expect((thrown as SiaxError).message).toContain('N0D');
});
it("escapes a literal by DOUBLING the quote, which is DuckDB's only escape", () => {
// MEASURED on DuckDB 1.5.5 rather than assumed, because the whole escaping
// strategy rests on it: `SELECT length('a\\b')` returns 4, so a backslash
// is a literal character; and `SELECT 'a\''` is a PARSER ERROR
// ("unterminated quoted string"), which is what a non-escaping backslash
// produces. A dialect where `\'` closed the literal would make doubling
// insufficient.
expect(escapeLiteral("O'Brien")).toBe("O''Brien");
// A backslash is NOT an escape inside a DuckDB single-quoted string, so it
// must survive untouched rather than being "helpfully" doubled.
expect(escapeLiteral("a\\'b")).toBe("a\\''b");
expect(escapeLiteral("x' OR '1'='1")).toBe("x'' OR ''1''=''1");
});
it('refuses a NUL byte in a filter value rather than escaping it', () => {
expect(() => escapeLiteral('a\u0000b')).toThrowError(SiaxError);
});
it('declares which formats have actually been PROVEN, and why the others have not', () => {
const readiness = provider(capturingRunner()).readiness();
expect(readiness.proven).toContain('parquet');
expect(readiness.proven).toContain('iceberg');
const arrow = readiness.unproven.find((u) => u.format === 'arrow_ipc');
expect(arrow?.reason).toContain('network install');
});
});
describe('BI-P014 — the committed evidence, checked by a test that never skips', () => {
it('the evidence file exists', () => {
expect(
fs.existsSync(EVIDENCE_PATH),
'evidence/bi-p014/duckdb-sandbox.json is missing — run the live suite with DUCKDB_PYTHON set',
).toBe(true);
});
const evidence = fs.existsSync(EVIDENCE_PATH)
? (JSON.parse(fs.readFileSync(EVIDENCE_PATH, 'utf8')) as Record<string, never>)
: ({} as Record<string, never>);
it('was recorded against a real DuckDB', () => {
expect(String(evidence.duckdb_version ?? '')).toMatch(/^\d+\.\d+/);
});
it('PINS the finding: allowed_directories alone read /etc/passwd', () => {
// The load-bearing assertion of this whole file. If someone "simplifies"
// the bootstrap down to the allow-list, the module comment would become a
// lie quietly; this keeps the measurement attached to the build.
const naive = evidence.naive_allowed_directories_is_a_noop as unknown as {
read_etc_passwd_succeeded: boolean;
statements: string[];
};
expect(naive).toBeDefined();
expect(naive.read_etc_passwd_succeeded).toBe(true);
expect(naive.statements.join(' ')).not.toContain('enable_external_access');
});
it('records that NO bypass attempt succeeded with the full bootstrap', () => {
const attempts = evidence.sandbox_bypass_attempts as unknown as Array<{ id: string; succeeded: boolean }>;
expect(Array.isArray(attempts)).toBe(true);
expect(attempts.length).toBeGreaterThanOrEqual(10);
expect(attempts.filter((a) => a.succeeded).map((a) => a.id)).toEqual([]);
});
it('records the counterfactual — the mounted file WAS readable in the same session', () => {
// Without this, "no attempt succeeded" is equally consistent with a
// sandbox that blocks everything, which is an outage, not a control.
expect(evidence.counterfactual_mounted_file_readable).toBe(true);
});
it('records the Iceberg snapshot read, whose fixture is deliberately not committed', () => {
// pyiceberg embeds ABSOLUTE paths in its manifests, so a committed Iceberg
// table would only work on the machine that built it. The recorded result
// is the artefact instead.
const iceberg = evidence.iceberg as unknown as { totals: Record<string, number> };
expect(iceberg?.totals).toEqual({ NO: 20, SE: 40 });
});
it('records that a locked session could not re-open its own sandbox', () => {
const reopened = evidence.reconfiguration_refused as unknown as Array<{ refused: boolean }>;
expect(reopened.length).toBe(3);
expect(reopened.every((r) => r.refused)).toBe(true);
});
});
describe('BI-P014 — the committed fixtures', () => {
it('ships the Parquet and CSV fixtures the live suite reads', () => {
expect(fs.existsSync(path.join(FIXTURES, 'sales.parquet'))).toBe(true);
expect(fs.existsSync(path.join(FIXTURES, 'customers.csv'))).toBe(true);
});
it('does NOT ship the Iceberg table, because its manifests carry absolute paths', () => {
// Asserted against GIT, not the filesystem. The claim is about what is
// COMMITTED; the live suite REBUILDS that table locally, so a filesystem
// check fails whenever both suites run in one pass — it was testing the
// wrong thing rather than catching a real problem.
const tracked = execFileSync('git', ['ls-files', 'tests/fixtures/duckdb/'], {
encoding: 'utf8',
cwd: process.cwd(),
})
.split('\n')
.filter(Boolean);
// The parquet/csv fixtures ARE tracked, so a version that shipped nothing
// would fail here too rather than passing vacuously.
expect(tracked.length).toBeGreaterThan(0);
expect(tracked.filter((f) => f.includes('/iceberg'))).toEqual([]);
});
});