CI (SIAX Cloud) / security (push) Successful in 41s
Security Scan (SIAX Cloud) / security (push) Successful in 40s
CI (SIAX Cloud) / integration (push) Successful in 52s
CI (SIAX Cloud) / quality (push) Successful in 1m5s
CI (SIAX Cloud) / security (pull_request) Successful in 31s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 29s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
CI (SIAX Cloud) / quality (pull_request) Successful in 53s
Implements the eight BI program prompts that make 0pt the canonical SIAX
analytics capability. Every file is NEW: not one existing file is modified,
and src/analytics/clickhouse-analytics.ts is byte-identical to main.
BI-P002 — the nineteen canonical entities, verbatim, with tenant_id +
project_id on every tenant-owned record. sql/008 gives each one a table.
The four version entities BI-P002 names as immutable are enforced by BEFORE
UPDATE/DELETE triggers, not by a TypeScript `readonly` — proven by direct SQL
against a live Postgres.
BI-P003 — RAW/NORMALIZED/CURATED/SERVING with the read rule enforced:
assertReadableByBiSurface refuses a RAW dataset to a dashboard unless a
recorded exception exists. All eight required declarations are NOT NULL.
BI-P004 — DatasetContract and a fail-closed compatibility gate. Fifteen
breaking-change classes are each tested twice: refused under a minor bump,
accepted under a major one.
BI-P005 — Arrow IPC alongside JSON, with a hand-written FlatBuffers writer so
no npm dependency (or CVE surface) is added. Conformance comes from three
independent implementations, not from self-agreement: pyarrow 25.0.1 and
DuckDB 1.5.5 read identical values from both ClickHouse's Arrow and ours, and
ClickHouse's own Arrow C++ ingests our 20,000-row batch with exact aggregates.
BI-P009 — SiaxAnalyticsEngine with all eight capabilities. The existing
ClickHouseAnalytics is COMPOSED, never replaced. executeQuery (analyst SQL) is
declared and deliberately refused with CAPABILITY_UNAVAILABLE until BI-P017 and
BI-P033 land, rather than opening the raw-SQL hole BI-P011 enumerates.
BI-P010 — four purpose-built tables, not one giant one, and the difference is
measured against the counterfactual: 48.8ms/40,960 rows vs 174.4ms/5,400,000
rows for the same question; 5/660 granules vs 660/1149.
BI-P011 — layered isolation, each layer proven with the layer above removed.
Without the forced predicate the same statement returns 50,050 (both tenants);
with it, t_alpha gets 50. Without a row policy an unrestricted user sees 2
tenants; the restricted user running the IDENTICAL statement sees 1.
BI-P012 — every named limit, fired individually and identified by ClickHouse
error code, plus the half usually skipped: an unrelated tenant is timed while
a real CPU bomb is in flight and must still return the CORRECT answer.
Three defects found by writing tests that can fail:
* `CHECK (array_length(purposes,1) >= 1)` can NEVER fail — array_length is
NULL for '{}' and a NULL CHECK passes. Fixed with coalesce(); the test
that caught it is pinned.
* EXPLAIN ESTIMATE run under the caller's own max_rows_to_read fails with
code 158 exactly when the estimate would be over budget. Planner calls now
use a narrower envelope.
* result_overflow_mode=break returns HTTP 200 with 65,409 of 100,000 rows
and no indication it is short. Every SELECT ships `throw`.
Verified rather than assumed: server7's ClickHouse holds `opt_scraped` (51
tables, 407,793,471 rows, `persons`/`cz_owners` included) — the b0ts plane this
module is prohibited from touching — has no `opt_analytics` database, zero
tenant_id columns and zero row policies. Nothing there was modified; every
proof ran against disposable containers.
Evidence: evidence/bi-p002-p012/{test-transcript.txt,bi-p010-benchmark.json,
bi-p005-arrow-roundtrip.json,server7-clickhouse-probe.md}
Docs: docs/analytics/BI_ANALYTICS_CORE.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
/**
|
|
* BI-P009 DoD: "Provider contract tests added."
|
|
*
|
|
* This is the contract every `SiaxAnalyticsEngine` provider must satisfy
|
|
* regardless of engine — ClickHouse today, DuckDB (BI-P014) and Trino
|
|
* (BI-P015) later. It is written against the INTERFACE, so a future provider
|
|
* imports `analyticsEngineContract` and gets the same bar.
|
|
*
|
|
* The identity rules here are the ones that matter most: this estate has
|
|
* already shipped a live cross-tenant hole by reading the tenant out of a
|
|
* REQUEST BODY (INF0 Wave 2). So the contract asserts that a request field
|
|
* named `tenant_id` cannot influence the engine at all.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { ClickHouseAnalyticsEngine } from "@/src/analytics/engine/clickhouse-engine";
|
|
import {
|
|
SIAX_ANALYTICS_ENGINE_CAPABILITIES,
|
|
type AnalyticsIdentity,
|
|
type SiaxAnalyticsEngine,
|
|
} from "@/src/analytics/engine/siax-analytics-engine";
|
|
|
|
const IDENTITY: AnalyticsIdentity = {
|
|
tenant_id: "t_alpha",
|
|
project_id: "p_default",
|
|
principal_id: "usr_1",
|
|
};
|
|
|
|
/** Reusable contract suite — future providers call this with their factory. */
|
|
export function analyticsEngineContract(name: string, make: () => SiaxAnalyticsEngine): void {
|
|
describe(`SiaxAnalyticsEngine contract: ${name}`, () => {
|
|
it("implements all eight BI-P009 capabilities as callable methods", () => {
|
|
const engine = make() as unknown as Record<string, unknown>;
|
|
for (const cap of SIAX_ANALYTICS_ENGINE_CAPABILITIES) {
|
|
expect(typeof engine[cap], `${cap} is not a method`).toBe("function");
|
|
}
|
|
expect(SIAX_ANALYTICS_ENGINE_CAPABILITIES).toEqual([
|
|
"health",
|
|
"executeMetric",
|
|
"executeQuery",
|
|
"estimateQuery",
|
|
"cancelQuery",
|
|
"explain",
|
|
"ingestProjection",
|
|
"refreshProjection",
|
|
]);
|
|
});
|
|
|
|
it("names its provider", () => {
|
|
expect(make().provider.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("refuses every tenant-scoped capability without a verified identity", async () => {
|
|
const engine = make();
|
|
const blank = { tenant_id: "", project_id: "p", principal_id: "usr_1" };
|
|
const noPrincipal = { tenant_id: "t_alpha", project_id: "p", principal_id: "" };
|
|
for (const bad of [blank, noPrincipal]) {
|
|
await expect(engine.executeMetric(bad, { metric: "m", aggregate: "sum" })).rejects.toMatchObject({
|
|
code: "UNAUTHENTICATED",
|
|
});
|
|
await expect(engine.estimateQuery(bad, { metric: "m", aggregate: "sum" })).rejects.toMatchObject({
|
|
code: "UNAUTHENTICATED",
|
|
});
|
|
await expect(engine.ingestProjection(bad, { dataset_id: "ds", rows: [] })).rejects.toMatchObject({
|
|
code: "UNAUTHENTICATED",
|
|
});
|
|
await expect(engine.refreshProjection(bad, "ds")).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
|
|
await expect(engine.cancelQuery(bad, "q")).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
|
|
}
|
|
});
|
|
|
|
it("a tenant_id smuggled into the REQUEST cannot influence the engine (INF0 Wave-2 bug class)", async () => {
|
|
const engine = make();
|
|
const smuggled = { metric: "m", aggregate: "sum", tenant_id: "t_victim" } as never;
|
|
// The request type has no tenant field; if a provider ever started
|
|
// reading one, this call would target t_victim. The engine must still
|
|
// fail on the IDENTITY, proving the request field is inert.
|
|
await expect(
|
|
engine.executeMetric({ tenant_id: "", project_id: "p", principal_id: "u" }, smuggled),
|
|
).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
|
|
});
|
|
|
|
it("cancelQuery refuses a query id belonging to another tenant", async () => {
|
|
const engine = make();
|
|
await expect(engine.cancelQuery(IDENTITY, "opt_deadbeefdead_someoneelses")).rejects.toMatchObject({
|
|
code: "AUTHORIZATION_DENIED",
|
|
});
|
|
});
|
|
|
|
it("health() reports unhealthy rather than throwing when the engine is unreachable", async () => {
|
|
const report = await make().health();
|
|
expect(report.healthy).toBe(false);
|
|
expect(report.provider.length).toBeGreaterThan(0);
|
|
// Never leak an engine body or a DSN into the operator-facing detail.
|
|
expect(report.detail ?? "").not.toMatch(/http|password|@/i);
|
|
});
|
|
});
|
|
}
|
|
|
|
analyticsEngineContract(
|
|
"clickhouse (unreachable host — contract-level assertions only)",
|
|
() =>
|
|
new ClickHouseAnalyticsEngine({
|
|
baseUrl: "http://clickhouse.invalid:8123",
|
|
database: "opt_contract_test",
|
|
timeoutMs: 500,
|
|
}),
|
|
);
|
|
|
|
describe("BI-P009 refusals that are deliberate, not missing", () => {
|
|
it("executeQuery (analyst SQL) is declared and REFUSED until BI-P017/BI-P033", async () => {
|
|
const engine = new ClickHouseAnalyticsEngine({ baseUrl: "http://clickhouse.invalid:8123" });
|
|
await expect(engine.executeQuery(IDENTITY, { sql: "SELECT 1", dataset_ids: [] })).rejects.toMatchObject({
|
|
code: "CAPABILITY_UNAVAILABLE",
|
|
});
|
|
});
|
|
|
|
it("the PRESERVED ClickHouseAnalytics is reachable through the engine, not replaced by it", () => {
|
|
const engine = new ClickHouseAnalyticsEngine({ baseUrl: "http://clickhouse.invalid:8123" });
|
|
expect(engine.legacyAnalytics.constructor.name).toBe("ClickHouseAnalytics");
|
|
expect(typeof engine.legacyAnalytics.queryMetric).toBe("function");
|
|
expect(typeof engine.legacyAnalytics.ensureSchema).toBe("function");
|
|
expect(typeof engine.legacyAnalytics.ingest).toBe("function");
|
|
});
|
|
});
|