Skip to content

Commit 47d1bc0

Browse files
committed
test(dogfood): pin that the object-existence gates are WIRED, not just implemented (#4613)
#3770 (data path) and #3867 (analytics cube auto-inference) are both implemented and unit-tested. Neither implementation is what this test protects: it protects the WIRING, which nothing asserted. Measured, not assumed. Deleting the five lines in `service-analytics/src/plugin.ts` that hand `isRegisteredObject` to the service — switching the #3867 gate off in production while leaving the implementation and every unit test untouched — left the whole repo green: service-analytics 299/299, dogfood 395/395. The gate only logs a one-shot stand-down `warn`, which nobody asserts, so `/analytics/query` silently reverts to "any table the connection can see is readable", the exact state #3867 was filed for. Deleting #3770's call site from `findData`, by contrast, reddens four tests immediately. The asymmetry is the test SHAPE, not luck. #3770's suite drives the real `protocol.findData`, so the call site is inside the object under test. #3867's suite injects the probe as config (`new AnalyticsService({ isRegisteredObject })`), so it proves how the service behaves once handed a probe — never that anyone hands it one. Prime Directive #10's closing line in mirror image: a `case` label is not enforcement, check the CALL SITE. This repo has already paid for this exact gap once — `analytics-rls`'s own header records that every pre-existing analytics RLS test injected `getReadScope` into a hand-built service, none booted the real plugin, and "which is how the gap shipped" (#3597). #3867 sits in the identical position, and the pattern is spreading: `measure-source-field-gate.test.ts` is a newer gate wired the same injected way. The new gate goes through `bootStack` (real plugin lifecycle, real Hono app, real HTTP) and never constructs a service by hand. Verified to bite: with this test in place, experiment A reddens 3 cases and experiment B reddens 1. Probe target is `sqlite_master`, not an invented name: the harness boots `sqlite-wasm`, so it is guaranteed to exist and hold rows while never being a registered object. That is the case that actually regressed — #3867's live repro read real rows out of it — and it keeps the gate honest, since a 404 here cannot be explained away by "there was no table anyway", the loophole the pre-#3770 driver-error-string 404 depended on. Also asserted: a premise check that the table IS readable straight through the engine (ground truth, and it pins #3770's choice to gate at the protocol ingress rather than the engine, so internal callers keep working), and controls proving a registered object still serves on both surfaces — without which a simply-broken boot would make every 404 above pass for the wrong reason. Test-only; releases nothing (empty changeset). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHv5StvA4KDkHhx19xufX
1 parent 0c681c3 commit 47d1bc0

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
test(dogfood): pin that the #3770 / #3867 object-existence gates are actually WIRED, not just implemented (#4613). Both gates had unit coverage of the implementation and none of the production wiring: deleting the five lines in `service-analytics/src/plugin.ts` that hand `isRegisteredObject` to the service left the entire repo green (service-analytics 299/299, dogfood 395/395) while `/analytics/query` silently reverted to reading any table the connection can see. The new boot-level gate goes through `bootStack` + real HTTP and reddens on both deletions (3 cases for #3867, 1 for #3770), measured. Test-only; releases nothing.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// BOOT-LEVEL ratchet for the two object-existence gates — #3770 (data path)
4+
// and #3867 (analytics cube auto-inference).
5+
//
6+
// Both gates are implemented and unit-tested. Neither implementation is what
7+
// this file protects: it protects the WIRING, which nothing asserted.
8+
//
9+
// Measured, not assumed. Deleting the five lines in
10+
// `service-analytics/src/plugin.ts` that hand `isRegisteredObject` to the
11+
// service — i.e. switching the #3867 gate off in production while leaving the
12+
// implementation and every unit test untouched — left the whole repo green:
13+
// service-analytics 299/299, dogfood 395/395. The gate only logs a one-shot
14+
// stand-down `warn`, which nobody asserts, so `/analytics/query` silently
15+
// reverts to "any table the connection can see is readable" — the exact state
16+
// #3867 was filed for. Deleting #3770's call site from `findData`, by contrast,
17+
// reddens four tests immediately.
18+
//
19+
// The asymmetry is the test SHAPE, not luck. #3770's suite drives the real
20+
// `protocol.findData`, so the call site is inside the object under test.
21+
// #3867's suite injects the probe as config (`new AnalyticsService({
22+
// isRegisteredObject })`), so it proves how the service behaves once handed a
23+
// probe — never that anyone hands it one. That is Prime Directive #10's
24+
// closing line in mirror image: a `case` label is not enforcement, check the
25+
// CALL SITE. (#3106 was the same lesson pointing the other way.)
26+
//
27+
// This repo has already paid for this exact gap once. From
28+
// `analytics-rls.dogfood.test.ts`, verbatim:
29+
//
30+
// > Every pre-existing analytics RLS test injects `getReadScope` as a fake
31+
// > into a hand-built AnalyticsService. NONE booted the real plugin, so the
32+
// > `getReadScope → security.getReadFilter` auto-bridge had zero coverage —
33+
// > which is how the gap shipped.
34+
//
35+
// That was #3597. #3867 sits in the identical position, and the pattern is
36+
// spreading — `measure-source-field-gate.test.ts` is a newer gate wired the
37+
// same injected way.
38+
//
39+
// Everything below therefore goes through `bootStack` (real plugin lifecycle,
40+
// real Hono app, real HTTP) and NEVER constructs a service by hand. See #4613.
41+
42+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
43+
import { bootStack, type VerifyStack } from '@objectstack/verify';
44+
import { defineStack } from '@objectstack/spec';
45+
import { ObjectSchema, Field } from '@objectstack/spec/data';
46+
47+
/**
48+
* The probe target: SQLite's internal schema table.
49+
*
50+
* Chosen deliberately over an invented name. The harness boots `sqlite-wasm`,
51+
* so `sqlite_master` is guaranteed to EXIST and hold rows, while never being a
52+
* registered object. That makes it the case that actually regressed — #3867's
53+
* live repro read real rows out of it (`{index: …, table: …, view: …}`) — and
54+
* it keeps this gate honest: a 404 here cannot be explained away by "there was
55+
* no table anyway", which is the loophole the pre-#3770 driver-error-string
56+
* 404 depended on.
57+
*/
58+
const UNREGISTERED_BUT_REAL = 'sqlite_master';
59+
60+
/** The control object — proves the surfaces work at all on this boot. */
61+
const GateNote = ObjectSchema.create({
62+
name: 'gate_note',
63+
sharingModel: 'public_read_write',
64+
label: 'Gate Note',
65+
pluralLabel: 'Gate Notes',
66+
fields: {
67+
name: Field.text({ label: 'Name', required: true }),
68+
},
69+
});
70+
71+
const gateStack = defineStack({
72+
manifest: {
73+
id: 'com.dogfood.registry_gate',
74+
namespace: 'gate',
75+
version: '0.0.0',
76+
type: 'app',
77+
name: 'Registry Gate Fixture',
78+
description: 'One object; the rest of the fixture is the ABSENCE of objects.',
79+
},
80+
objects: [GateNote],
81+
});
82+
83+
describe('dogfood: the object-existence gates are actually WIRED (#3770, #3867, #4613)', () => {
84+
let stack: VerifyStack;
85+
let adminToken: string;
86+
87+
beforeAll(async () => {
88+
// Vanilla boot on purpose — no plugin overrides. The whole point is that
89+
// the DEFAULT production wiring carries the gates.
90+
stack = await bootStack(gateStack as never);
91+
adminToken = await stack.signIn();
92+
93+
const created = await stack.apiAs(adminToken, 'POST', '/data/gate_note', { name: 'control' });
94+
expect(created.status).toBeLessThan(300);
95+
}, 90_000);
96+
97+
afterAll(async () => {
98+
await stack?.stop();
99+
});
100+
101+
// ─────────────────────────────────────────────────────────────
102+
// Premise — the probe target really is a readable table.
103+
// Without this, every 404 below could be passing for the wrong reason.
104+
// ─────────────────────────────────────────────────────────────
105+
106+
it('premise: the unregistered name IS a real table the engine can read', async () => {
107+
// Straight through the ENGINE, which is deliberately ungated — #3770 put
108+
// the gate at the protocol ingress, the external API boundary, precisely so
109+
// internal callers (hooks, flows, migrations, raw ObjectQL) keep working.
110+
// So this both establishes ground truth and pins that boundary choice.
111+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112+
const ql = await stack.kernel.getServiceAsync<any>('objectql');
113+
const rows = (await ql.find(UNREGISTERED_BUT_REAL, { context: { isSystem: true } })) as unknown[];
114+
expect(Array.isArray(rows)).toBe(true);
115+
expect(rows.length).toBeGreaterThan(0);
116+
});
117+
118+
// ─────────────────────────────────────────────────────────────
119+
// #3770 — the data path's registry gate, over real HTTP
120+
// ─────────────────────────────────────────────────────────────
121+
122+
it('#3770: GET /data/:object 404s an unregistered object whose table exists', async () => {
123+
const res = await stack.apiAs(adminToken, 'GET', `/data/${UNREGISTERED_BUT_REAL}`);
124+
expect(res.status).toBe(404);
125+
126+
const body = (await res.json()) as { code?: string; error?: unknown };
127+
// ADR-0112: top-level `error.code` is SCREAMING_SNAKE on the wire.
128+
expect(body.code).toBe('OBJECT_NOT_FOUND');
129+
// And it must not have leaked the table's contents in any shape.
130+
expect(JSON.stringify(body)).not.toContain('CREATE TABLE');
131+
});
132+
133+
it('#3770: the write verbs are gated too, not just the read', async () => {
134+
const post = await stack.apiAs(adminToken, 'POST', `/data/${UNREGISTERED_BUT_REAL}`, { x: 1 });
135+
expect(post.status).toBe(404);
136+
});
137+
138+
// ─────────────────────────────────────────────────────────────
139+
// #3867 — the analytics cube-inference gate, over real HTTP.
140+
// These are the ones that were completely unprotected.
141+
// ─────────────────────────────────────────────────────────────
142+
143+
it('#3867: POST /analytics/query 404s a cube that is neither a Cube nor an object', async () => {
144+
// Pre-#3867 this returned 200 with real rows aggregated out of the table.
145+
const res = await stack.apiAs(adminToken, 'POST', '/analytics/query', {
146+
cube: UNREGISTERED_BUT_REAL,
147+
measures: ['count'],
148+
});
149+
expect(res.status).toBe(404);
150+
});
151+
152+
it('#3867: a grouped query over the same name is refused identically', async () => {
153+
// The shape that actually disclosed data in the #3867 repro: grouping by a
154+
// real column of the unregistered table returned its rows.
155+
const res = await stack.apiAs(adminToken, 'POST', '/analytics/query', {
156+
cube: UNREGISTERED_BUT_REAL,
157+
measures: ['count'],
158+
dimensions: ['type'],
159+
});
160+
expect(res.status).toBe(404);
161+
162+
const raw = await res.text();
163+
// The disclosed values from the original repro must not appear in any form.
164+
expect(raw).not.toContain('"rows"');
165+
});
166+
167+
it('#3867: /analytics/sql is gated too — no SQL naming an arbitrary table', async () => {
168+
const res = await stack.apiAs(adminToken, 'POST', '/analytics/sql', {
169+
cube: UNREGISTERED_BUT_REAL,
170+
measures: ['count'],
171+
});
172+
expect(res.status).toBe(404);
173+
expect(await res.text()).not.toContain(`FROM "${UNREGISTERED_BUT_REAL}"`);
174+
});
175+
176+
// ─────────────────────────────────────────────────────────────
177+
// Controls — a boot where these surfaces are simply broken would make every
178+
// assertion above pass. These make that failure mode impossible.
179+
// ─────────────────────────────────────────────────────────────
180+
181+
it('control: a REGISTERED object is served on the same data route', async () => {
182+
const res = await stack.apiAs(adminToken, 'GET', '/data/gate_note');
183+
expect(res.status).toBe(200);
184+
const body = (await res.json()) as { records?: unknown[] };
185+
expect(body.records ?? []).toHaveLength(1);
186+
});
187+
188+
it('control: a REGISTERED object still auto-infers a cube (the KPI path)', async () => {
189+
// #3867 narrowed auto-inference; it must not have removed it. `gate_note`
190+
// has no authored Cube, so a 200 here proves the intended
191+
// "metric over an object" path survived the gate.
192+
const res = await stack.apiAs(adminToken, 'POST', '/analytics/query', {
193+
cube: 'gate_note',
194+
measures: ['count'],
195+
});
196+
expect(res.status).toBe(200);
197+
});
198+
});

0 commit comments

Comments
 (0)