327 lines
13 KiB
TypeScript
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([]);
|
|
});
|
|
});
|