CI (SIAX Cloud) / quality (push) Successful in 1m22s
CI (SIAX Cloud) / integration (push) Successful in 1m7s
CI (SIAX Cloud) / security (push) Successful in 34s
Security Scan (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m8s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 28s
BI-P002 named DashboardDefinition/DashboardVersion and sql/008 stored them, but `layout_json` was a string, and a string holds anything — including a Superset export only Superset can read back. This gives the document a body and makes the DoD checkable rather than asserted. planDashboard() is the DoD: a document in, runnable AnalyticsQueryRequest objects (BI-P016) plus visualization specs out. The test greps the whole serialized plan for vendor traces after first proving the detector CAN find one in the source document — a clean grep over a broken detector is the usual way this test passes for the wrong reason. The counterfactual is a test too: a widget whose only content is a Superset slice id is REFUSED, because that is exactly the document that cannot be reconstructed. vendor_ref survives for round-tripping but is excluded from the canonical hash, so re-exporting to a new Superset instance does not fabricate a new dashboard version. No SQL surface: query widgets are parsed by parseAnalyticsQueryRequest, so `sql`, `where` and `tenant_id` are refused structurally rather than by review. 18 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
/**
|
|
* tests/analytics/dashboard-model.test.ts
|
|
* ========================================
|
|
* BI-P060 — ANALYTICS DASHBOARD MODEL.
|
|
*
|
|
* The DoD is "a native SIAX dashboard can be reconstructed without Superset",
|
|
* so the central test takes a document that CAME from Superset (it still
|
|
* carries the slice ids in `vendor_ref`), plans it, and then greps the whole
|
|
* plan for any trace of the vendor. A plan that still needs Superset to be
|
|
* understood fails here.
|
|
*
|
|
* The counterfactual matters as much: `vendor-only-widget-is-refused` builds
|
|
* the document that CANNOT be reconstructed — a widget whose only content is a
|
|
* Superset id — and requires the model to refuse it. Without that test the
|
|
* vendor-free assertion would pass trivially for documents nobody could
|
|
* render.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { SiaxError } from '@/src/platform/errors';
|
|
import {
|
|
dashboardDocumentHash,
|
|
parseDashboardDocument,
|
|
planDashboard,
|
|
VISUALIZATION_TYPES,
|
|
type DashboardDocument,
|
|
} from '@/src/analytics/dashboards/dashboard-document';
|
|
|
|
/** A dashboard exported from Superset and rewritten into the canonical model. */
|
|
function supersetImportedDocument(): unknown {
|
|
return {
|
|
id: 'dash_sales_overview',
|
|
title: 'Sales overview',
|
|
description: 'Migrated from Superset dashboard 41.',
|
|
version: 1,
|
|
layout: { grid_columns: 12, row_height_px: 40 },
|
|
parameters: [
|
|
{
|
|
name: 'region',
|
|
type: 'enum',
|
|
required: false,
|
|
default_value: 'SE',
|
|
allowed_values: ['SE', 'NO', 'DK'],
|
|
},
|
|
{ name: 'since', type: 'date', required: true },
|
|
],
|
|
filters: [
|
|
{
|
|
filter_id: 'f_region',
|
|
label: 'Region',
|
|
dimension: 'region',
|
|
operator: 'eq',
|
|
default_values: ['{{region}}'],
|
|
},
|
|
{
|
|
filter_id: 'f_channel',
|
|
label: 'Channel',
|
|
dimension: 'channel',
|
|
operator: 'in',
|
|
default_values: ['web'],
|
|
},
|
|
{
|
|
filter_id: 'f_tenant_scope',
|
|
label: 'Business unit',
|
|
dimension: 'business_unit',
|
|
operator: 'eq',
|
|
default_values: ['bu_north'],
|
|
locked: true,
|
|
},
|
|
],
|
|
widgets: [
|
|
{
|
|
widget_id: 'w_revenue_total',
|
|
title: 'Revenue',
|
|
source: { kind: 'metric', dataset: 'ds_sales', metric: 'revenue' },
|
|
visualization: { type: 'big_number', encoding: { value: 'revenue' } },
|
|
layout: { x: 0, y: 0, w: 3, h: 2 },
|
|
vendor_ref: { system: 'superset', id: 'slice-8812' },
|
|
},
|
|
{
|
|
widget_id: 'w_revenue_by_day',
|
|
title: 'Revenue by day',
|
|
source: {
|
|
kind: 'metric',
|
|
dataset: 'ds_sales',
|
|
metric: 'revenue',
|
|
dimensions: ['order_date'],
|
|
grain: 'day',
|
|
},
|
|
visualization: {
|
|
type: 'line',
|
|
encoding: { x: 'order_date', y: ['revenue'] },
|
|
options: { stacked: false },
|
|
},
|
|
layout: { x: 3, y: 0, w: 9, h: 4 },
|
|
filter_bindings: ['f_region', 'f_tenant_scope'],
|
|
vendor_ref: { system: 'superset', id: 'slice-8813' },
|
|
},
|
|
{
|
|
widget_id: 'w_top_products',
|
|
title: 'Top products',
|
|
source: {
|
|
kind: 'query',
|
|
request: {
|
|
dataset: 'ds_sales',
|
|
metrics: ['revenue', 'orders'],
|
|
dimensions: ['product'],
|
|
filters: [{ dimension: 'status', operator: 'eq', values: ['completed'] }],
|
|
orderBy: [{ ref: 'revenue', direction: 'desc' }],
|
|
limit: 20,
|
|
},
|
|
},
|
|
visualization: { type: 'table', encoding: { x: 'product', y: ['revenue', 'orders'] } },
|
|
layout: { x: 0, y: 4, w: 12, h: 6 },
|
|
},
|
|
],
|
|
refresh: { mode: 'interval', interval_seconds: 300, max_staleness_seconds: 900 },
|
|
permissions: {
|
|
visibility: 'roles',
|
|
roles: ['analyst', 'sales_lead'],
|
|
required_entitlement: 'bi_dashboards',
|
|
},
|
|
vendor_ref: { system: 'superset', id: 'dashboard-41' },
|
|
};
|
|
}
|
|
|
|
describe('BI-P060 dashboard model — reconstruction without Superset', () => {
|
|
it('plans every widget into a runnable analytics request', () => {
|
|
const doc = parseDashboardDocument(supersetImportedDocument());
|
|
const plan = planDashboard(doc, { parameters: { since: '2026-01-01', region: 'NO' } });
|
|
|
|
expect(plan.widgets).toHaveLength(3);
|
|
for (const w of plan.widgets) {
|
|
expect(w.request.dataset).toBe('ds_sales');
|
|
expect(w.request.metrics.length).toBeGreaterThan(0);
|
|
expect(VISUALIZATION_TYPES).toContain(w.visualization.type);
|
|
}
|
|
// The parameter reference was BOUND, not left as a template string.
|
|
const byDay = plan.widgets.find((w) => w.widget_id === 'w_revenue_by_day');
|
|
expect(byDay?.request.filters.find((f) => f.dimension === 'region')?.values).toEqual(['NO']);
|
|
expect(plan.bound_parameters).toEqual({ region: 'NO', since: '2026-01-01' });
|
|
});
|
|
|
|
it('the plan contains no vendor identifier anywhere', () => {
|
|
const doc = parseDashboardDocument(supersetImportedDocument());
|
|
const plan = planDashboard(doc, { parameters: { since: '2026-01-01' } });
|
|
const serialized = JSON.stringify(plan).toLowerCase();
|
|
|
|
// The detector must be capable of finding one: the document DOES carry
|
|
// these ids, so a grep that finds nothing in the plan means the plan is
|
|
// clean, not that the grep is broken.
|
|
expect(JSON.stringify(doc).toLowerCase()).toContain('slice-8812');
|
|
|
|
for (const trace of ['superset', 'slice-8812', 'slice-8813', 'dashboard-41', 'viz_type', 'form_data']) {
|
|
expect(serialized).not.toContain(trace);
|
|
}
|
|
});
|
|
|
|
it('vendor-only widget is refused — it is the document that cannot be reconstructed', () => {
|
|
const raw = supersetImportedDocument() as { widgets: unknown[] };
|
|
raw.widgets = [
|
|
{
|
|
widget_id: 'w_mystery',
|
|
title: 'Whatever Superset had',
|
|
source: { kind: 'vendor', system: 'superset', id: 'slice-9001' },
|
|
visualization: { type: 'line', encoding: {} },
|
|
layout: { x: 0, y: 0, w: 4, h: 3 },
|
|
},
|
|
];
|
|
expect(() => parseDashboardDocument(raw)).toThrow(SiaxError);
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/reconstruct/i);
|
|
});
|
|
|
|
it('vendor-native fields are refused at the top level and inside a widget', () => {
|
|
const top = { ...(supersetImportedDocument() as object), position_json: '{}' };
|
|
expect(() => parseDashboardDocument(top)).toThrow(/vendor-native/);
|
|
|
|
const raw = supersetImportedDocument() as { widgets: Record<string, unknown>[] };
|
|
raw.widgets[0].viz_type = 'echarts_timeseries';
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/vendor-native/);
|
|
});
|
|
|
|
it('a Superset viz_type value is not a SIAX visualization type', () => {
|
|
const raw = supersetImportedDocument() as { widgets: Record<string, unknown>[] };
|
|
raw.widgets[0].visualization = { type: 'echarts_timeseries', encoding: {} };
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/not a SIAX visualization type/);
|
|
});
|
|
});
|
|
|
|
describe('BI-P060 dashboard model — no SQL surface', () => {
|
|
it('a widget cannot carry SQL, however it is spelled', () => {
|
|
for (const key of ['sql', 'query_text', 'statement', 'where', 'expression']) {
|
|
const raw = supersetImportedDocument() as { widgets: Record<string, unknown>[] };
|
|
raw.widgets[0][key] = 'SELECT 1';
|
|
expect(() => parseDashboardDocument(raw), key).toThrow(SiaxError);
|
|
}
|
|
});
|
|
|
|
it('a query widget goes through the BI-P016 contract, so tenant selection is impossible', () => {
|
|
const raw = supersetImportedDocument() as { widgets: Record<string, unknown>[] };
|
|
raw.widgets[2].source = {
|
|
kind: 'query',
|
|
request: {
|
|
dataset: 'ds_sales',
|
|
metrics: ['revenue'],
|
|
dimensions: [],
|
|
filters: [],
|
|
tenant_id: 't_victim',
|
|
},
|
|
};
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/does not accept SQL, catalogs, or tenant selection/);
|
|
});
|
|
|
|
it('the document itself cannot name a tenant', () => {
|
|
const raw = { ...(supersetImportedDocument() as object), tenant_id: 't_victim' };
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/dashboards do not carry SQL|unknown field/);
|
|
});
|
|
});
|
|
|
|
describe('BI-P060 dashboard model — validation that would otherwise bite at render time', () => {
|
|
it('a widget binding an undeclared filter is refused', () => {
|
|
const raw = supersetImportedDocument() as { widgets: Record<string, unknown>[] };
|
|
raw.widgets[1].filter_bindings = ['f_nonexistent'];
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/undeclared filter/);
|
|
});
|
|
|
|
it('a filter defaulting to an undeclared parameter is refused', () => {
|
|
const raw = supersetImportedDocument() as { filters: Record<string, unknown>[] };
|
|
raw.filters[0].default_values = ['{{not_declared}}'];
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/undeclared parameter/);
|
|
});
|
|
|
|
it('a widget overflowing the grid is refused', () => {
|
|
const raw = supersetImportedDocument() as { widgets: Record<string, unknown>[] };
|
|
raw.widgets[1].layout = { x: 9, y: 0, w: 6, h: 3 };
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/overflows the 12-column grid/);
|
|
});
|
|
|
|
it('an interval refresh with a cron, and a manual refresh with a schedule, are both refused', () => {
|
|
const a = supersetImportedDocument() as Record<string, unknown>;
|
|
a.refresh = { mode: 'interval', interval_seconds: 300, cron: '0 * * * *', max_staleness_seconds: 900 };
|
|
expect(() => parseDashboardDocument(a)).toThrow(/meaningless/);
|
|
|
|
const b = supersetImportedDocument() as Record<string, unknown>;
|
|
b.refresh = { mode: 'manual', interval_seconds: 300, max_staleness_seconds: 900 };
|
|
expect(() => parseDashboardDocument(b)).toThrow(/manual/);
|
|
});
|
|
|
|
it('visibility "roles" with no roles is refused rather than guessed', () => {
|
|
const raw = supersetImportedDocument() as Record<string, unknown>;
|
|
raw.permissions = { visibility: 'roles', roles: [] };
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/deny all/);
|
|
});
|
|
|
|
it('a required parameter that was not supplied fails the plan, not the render', () => {
|
|
const doc = parseDashboardDocument(supersetImportedDocument());
|
|
expect(() => planDashboard(doc, { parameters: {} })).toThrow(/required parameter "since"/);
|
|
});
|
|
|
|
it('a locked filter cannot be switched off by a viewer', () => {
|
|
const doc = parseDashboardDocument(supersetImportedDocument());
|
|
expect(() =>
|
|
planDashboard(doc, { parameters: { since: '2026-01-01' }, disabled_filter_ids: ['f_tenant_scope'] }),
|
|
).toThrow(/locked/);
|
|
// An unlocked one can be, and disappears from the compiled request.
|
|
const plan = planDashboard(doc, {
|
|
parameters: { since: '2026-01-01' },
|
|
disabled_filter_ids: ['f_channel'],
|
|
});
|
|
const table = plan.widgets.find((w) => w.widget_id === 'w_top_products');
|
|
expect(table?.request.filters.map((f) => f.dimension)).not.toContain('channel');
|
|
});
|
|
|
|
it('an enum parameter without allowed_values is refused', () => {
|
|
const raw = supersetImportedDocument() as { parameters: Record<string, unknown>[] };
|
|
raw.parameters[0] = { name: 'region', type: 'enum', required: false, default_value: 'SE' };
|
|
expect(() => parseDashboardDocument(raw)).toThrow(/unbounded enum/);
|
|
});
|
|
});
|
|
|
|
/** Rebuild an object graph with every object's keys in reverse order. */
|
|
function shuffleKeys(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(shuffleKeys);
|
|
if (value !== null && typeof value === 'object') {
|
|
const out: Record<string, unknown> = {};
|
|
for (const k of Object.keys(value as object).reverse()) {
|
|
out[k] = shuffleKeys((value as Record<string, unknown>)[k]);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
describe('BI-P060 dashboard model — canonical hash', () => {
|
|
function parsed(): DashboardDocument {
|
|
return parseDashboardDocument(supersetImportedDocument());
|
|
}
|
|
|
|
it('is stable across key order and vendor re-export', () => {
|
|
const a = parsed();
|
|
const b = parsed();
|
|
b.vendor_ref = { system: 'superset', id: 'dashboard-999-after-migration' };
|
|
b.widgets[0].vendor_ref = { system: 'metabase', id: 'card-3' };
|
|
expect(dashboardDocumentHash(b)).toBe(dashboardDocumentHash(a));
|
|
|
|
// Key order in the source JSON must not change identity either.
|
|
expect(dashboardDocumentHash(parseDashboardDocument(shuffleKeys(supersetImportedDocument())))).toBe(
|
|
dashboardDocumentHash(a),
|
|
);
|
|
});
|
|
|
|
it('changes when meaning changes', () => {
|
|
const base = dashboardDocumentHash(parsed());
|
|
|
|
const retitled = parsed();
|
|
retitled.widgets[0].title = 'Net revenue';
|
|
expect(dashboardDocumentHash(retitled)).not.toBe(base);
|
|
|
|
const remetric = parsed();
|
|
(remetric.widgets[0].source as { metric: string }).metric = 'revenue_net';
|
|
expect(dashboardDocumentHash(remetric)).not.toBe(base);
|
|
|
|
const moved = parsed();
|
|
moved.widgets[1].layout = { x: 3, y: 1, w: 9, h: 4 };
|
|
expect(dashboardDocumentHash(moved)).not.toBe(base);
|
|
|
|
const refiltered = parsed();
|
|
refiltered.filters[1].default_values = ['store'];
|
|
expect(dashboardDocumentHash(refiltered)).not.toBe(base);
|
|
});
|
|
});
|