Skip to content

Commit d64f148

Browse files
committed
test(metadata-protocol): pin the generic data door's stored-body projection, both halves
Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8e9488 commit d64f148

1 file changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21086 — the generic data door (`findData` / `getData`) serves the stored
5+
* metadata body of a `sys_metadata` / `sys_metadata_history` row as its type's
6+
* read projection: the one every `/meta` read exit serves, through the same
7+
* `@objectstack/spec/kernel` redactor registry.
8+
*
9+
* Both halves are pinned, per row shape:
10+
*
11+
* - **withheld** — a stored credential is absent from what the door serves,
12+
* on the list read, the by-id read, a projection naming only the body, and a
13+
* version snapshot; a grouping by the body column is refused before the
14+
* engine is asked;
15+
* - **preserved** — a body that holds nothing to withhold reaches the caller
16+
* byte-for-byte (the stored string itself, never re-serialized), every
17+
* other column is untouched, a projection gets exactly the columns it named,
18+
* and every other object, and every other grouping, is served as before.
19+
*
20+
* Rows are seeded straight into a stub engine: the credentials under test are
21+
* legacy at-rest material (the write doors refuse most of these spellings), so
22+
* only a direct seed reproduces the population that matters.
23+
*/
24+
25+
import { afterEach, describe, expect, it, vi } from 'vitest';
26+
import { SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
27+
import { getMetadataTypeRedactor, registerMetadataTypeRedactor } from '@objectstack/spec/kernel';
28+
import { ObjectStackProtocolImplementation } from './protocol.js';
29+
30+
/** Stored credential values — each must be absent from every served body. */
31+
const SECRETS = [
32+
'stored-enc-key-value',
33+
'stored-inline-password',
34+
'stored-url-password',
35+
'stored-alias-token',
36+
'stored-nested-token',
37+
] as const;
38+
39+
/** A legacy turso row: the still-writable key plus an alias spelling. */
40+
const TURSO_BODY = {
41+
name: 'edge_cache',
42+
label: 'Edge Cache',
43+
driver: 'turso',
44+
config: {
45+
url: 'file:/var/data/edge.db',
46+
encryptionKey: 'stored-enc-key-value',
47+
authtoken: 'stored-alias-token',
48+
},
49+
};
50+
51+
/** A legacy postgres row: inline password, URL userinfo password, nested spelling. */
52+
const POSTGRES_BODY = {
53+
name: 'warehouse',
54+
label: 'Warehouse',
55+
driver: 'postgres',
56+
config: {
57+
url: 'postgresql://reporting:stored-url-password@db.internal:5432/warehouse',
58+
password: 'stored-inline-password',
59+
options: { deep: { authtoken: 'stored-nested-token' } },
60+
poolSize: 4,
61+
},
62+
};
63+
64+
/** A datasource that stores nothing to withhold. */
65+
const CLEAN_DATASOURCE_BODY = {
66+
name: 'local_sqlite',
67+
driver: 'sqlite',
68+
config: { url: 'file:/var/data/local.db' },
69+
};
70+
71+
/** A type that registers no redactor; its body mentions a credential-shaped key on purpose. */
72+
const VIEW_BODY = { name: 'all_tasks', label: 'All Tasks', type: 'grid', config: { password: 'not-a-credential' } };
73+
74+
/**
75+
* Stored strings are formatted (indented) on purpose: a body that is served
76+
* untouched must arrive as these exact bytes, which a parse-then-stringify
77+
* round trip would not reproduce.
78+
*/
79+
const stored = (body: unknown) => JSON.stringify(body, null, 2);
80+
81+
const SYS_METADATA_ROWS = [
82+
{ id: 'm_turso', type: 'datasource', name: 'edge_cache', state: 'active', metadata: stored(TURSO_BODY) },
83+
{ id: 'm_pg', type: 'datasource', name: 'warehouse', state: 'active', metadata: stored(POSTGRES_BODY) },
84+
{ id: 'm_clean', type: 'datasource', name: 'local_sqlite', state: 'active', metadata: stored(CLEAN_DATASOURCE_BODY) },
85+
{ id: 'm_view', type: 'view', name: 'all_tasks', state: 'active', metadata: stored(VIEW_BODY) },
86+
];
87+
88+
const HISTORY_ROWS = [
89+
{ id: 'h_pg_1', type: 'datasource', name: 'warehouse', version: 1, operation_type: 'create', metadata: stored(POSTGRES_BODY) },
90+
{ id: 'h_view_1', type: 'view', name: 'all_tasks', version: 1, operation_type: 'create', metadata: stored(VIEW_BODY) },
91+
];
92+
93+
/** An ordinary business object with a `metadata` column of its own. */
94+
const NOTE_SCHEMA = {
95+
name: 'note',
96+
fields: {
97+
type: { name: 'type', type: 'text' },
98+
metadata: { name: 'metadata', type: 'textarea' },
99+
},
100+
};
101+
const NOTE_ROWS = [{ id: 'n_1', type: 'datasource', metadata: stored(POSTGRES_BODY) }];
102+
103+
const ROWS: Record<string, Record<string, unknown>[]> = {
104+
sys_metadata: SYS_METADATA_ROWS,
105+
sys_metadata_history: HISTORY_ROWS,
106+
note: NOTE_ROWS,
107+
};
108+
const SCHEMAS: Record<string, unknown> = {
109+
sys_metadata: SysMetadataObject,
110+
sys_metadata_history: SysMetadataHistoryObject,
111+
note: NOTE_SCHEMA,
112+
};
113+
114+
function project(row: Record<string, unknown>, fields: unknown): Record<string, unknown> {
115+
if (!Array.isArray(fields)) return row;
116+
const out: Record<string, unknown> = { id: row.id };
117+
for (const f of fields as string[]) if (f in row) out[f] = row[f];
118+
return out;
119+
}
120+
121+
function matches(row: Record<string, unknown>, where: unknown): boolean {
122+
if (!where || typeof where !== 'object') return true;
123+
return Object.entries(where as Record<string, unknown>).every(([k, v]) => row[k] === v);
124+
}
125+
126+
function makeProtocol(rows: Record<string, Record<string, unknown>[]> = ROWS) {
127+
const find = vi.fn(async (object: string, opts: any) =>
128+
(rows[object] ?? []).filter((r) => matches(r, opts?.where)).map((r) => project(r, opts?.fields)));
129+
const findOne = vi.fn(async (object: string, opts: any) => {
130+
const hit = (rows[object] ?? []).find((r) => matches(r, opts?.where));
131+
return hit ? project(hit, opts?.fields) : null;
132+
});
133+
const aggregate = vi.fn(async () => [{ type: 'datasource', n: 3 }]);
134+
const engine = {
135+
registry: { getObject: (n: string) => SCHEMAS[n] },
136+
find,
137+
findOne,
138+
count: vi.fn(async () => 0),
139+
aggregate,
140+
};
141+
return { p: new ObjectStackProtocolImplementation(engine as any), find, findOne, aggregate };
142+
}
143+
144+
function expectNoSecret(served: unknown): void {
145+
const text = JSON.stringify(served);
146+
for (const secret of SECRETS) expect(text).not.toContain(secret);
147+
}
148+
149+
const byId = (records: any[], id: string) => records.find((r) => r.id === id);
150+
151+
describe('[#21086] findData — sys_metadata rows serve the read projection of their body', () => {
152+
it('withholds every stored credential spelling from the list read', async () => {
153+
const { p } = makeProtocol();
154+
const result: any = await p.findData({ object: 'sys_metadata', query: { type: 'datasource' } });
155+
156+
expect(result.records).toHaveLength(3);
157+
expectNoSecret(result.records);
158+
159+
const turso = JSON.parse(byId(result.records, 'm_turso').metadata);
160+
expect(turso.config).toEqual({ url: 'file:/var/data/edge.db' });
161+
162+
const pg = JSON.parse(byId(result.records, 'm_pg').metadata);
163+
expect(pg.config).toEqual({
164+
url: 'postgresql://reporting@db.internal:5432/warehouse',
165+
options: { deep: {} },
166+
poolSize: 4,
167+
});
168+
// The served body is the one `/meta` serves: the same registry entry.
169+
expect(pg).toEqual(getMetadataTypeRedactor('datasource')!(POSTGRES_BODY).item);
170+
});
171+
172+
it('preserves the other columns, and a body with nothing to withhold byte-for-byte', async () => {
173+
const { p } = makeProtocol();
174+
const result: any = await p.findData({ object: 'sys_metadata', query: {} });
175+
176+
for (const row of SYS_METADATA_ROWS) {
177+
const served = byId(result.records, row.id);
178+
expect(served.type).toBe(row.type);
179+
expect(served.name).toBe(row.name);
180+
expect(served.state).toBe(row.state);
181+
}
182+
// Untouched bodies keep the stored bytes (the fixture is indented).
183+
expect(byId(result.records, 'm_clean').metadata).toBe(stored(CLEAN_DATASOURCE_BODY));
184+
expect(byId(result.records, 'm_view').metadata).toBe(stored(VIEW_BODY));
185+
});
186+
187+
it('reads `type` for a projection naming only the body, and serves only the named columns', async () => {
188+
const { p, find } = makeProtocol();
189+
const result: any = await p.findData({ object: 'sys_metadata', query: { select: 'metadata' } });
190+
191+
expect(find.mock.calls[0]![1].fields).toEqual(['metadata', 'type']);
192+
expectNoSecret(result.records);
193+
for (const served of result.records) {
194+
expect(Object.keys(served).sort()).toEqual(['id', 'metadata']);
195+
}
196+
});
197+
198+
it('leaves a projection that names `type` itself, or no body, exactly as asked', async () => {
199+
const { p, find } = makeProtocol();
200+
const withType: any = await p.findData({ object: 'sys_metadata', query: { select: 'metadata,type' } });
201+
expect(find.mock.calls[0]![1].fields).toEqual(['metadata', 'type']);
202+
expectNoSecret(withType.records);
203+
expect(byId(withType.records, 'm_pg').type).toBe('datasource');
204+
205+
const noBody: any = await p.findData({ object: 'sys_metadata', query: { select: 'name' } });
206+
expect(find.mock.calls[1]![1].fields).toEqual(['name']);
207+
expect(Object.keys(noBody.records[0]).sort()).toEqual(['id', 'name']);
208+
});
209+
210+
it('serves a version snapshot (sys_metadata_history) the same projection', async () => {
211+
const { p } = makeProtocol();
212+
const result: any = await p.findData({ object: 'sys_metadata_history', query: {} });
213+
214+
expectNoSecret(result.records);
215+
expect(JSON.parse(byId(result.records, 'h_pg_1').metadata).config.password).toBeUndefined();
216+
expect(byId(result.records, 'h_view_1').metadata).toBe(stored(VIEW_BODY));
217+
});
218+
219+
it('does not touch an object outside the stored-metadata tables', async () => {
220+
const { p } = makeProtocol();
221+
const result: any = await p.findData({ object: 'note', query: {} });
222+
expect(result.records[0].metadata).toBe(stored(POSTGRES_BODY));
223+
});
224+
});
225+
226+
describe('[#21086] getData — the by-id read serves the same projection', () => {
227+
it('withholds the stored credential from the record', async () => {
228+
const { p } = makeProtocol();
229+
const result: any = await p.getData({ object: 'sys_metadata', id: 'm_turso' });
230+
231+
expectNoSecret(result.record);
232+
expect(JSON.parse(result.record.metadata).config).toEqual({ url: 'file:/var/data/edge.db' });
233+
expect(result.record.type).toBe('datasource');
234+
});
235+
236+
it('reads `type` for `select=metadata`, and serves only the named columns', async () => {
237+
const { p, findOne } = makeProtocol();
238+
const result: any = await p.getData({ object: 'sys_metadata', id: 'm_pg', select: 'metadata' });
239+
240+
expect(findOne.mock.calls[0]![1].fields).toEqual(['metadata', 'type']);
241+
expectNoSecret(result.record);
242+
expect(Object.keys(result.record).sort()).toEqual(['id', 'metadata']);
243+
});
244+
245+
it('serves a body with nothing to withhold as stored', async () => {
246+
const { p } = makeProtocol();
247+
const result: any = await p.getData({ object: 'sys_metadata', id: 'm_view' });
248+
expect(result.record.metadata).toBe(stored(VIEW_BODY));
249+
});
250+
});
251+
252+
describe('[#21086] grouping by the stored body column is refused before the engine runs', () => {
253+
for (const [label, groupBy, position] of [
254+
['field-name form', ['metadata'], 'groupBy[0]'],
255+
['object form', ['type', { field: 'metadata' }], 'groupBy[1].field'],
256+
] as const) {
257+
it(`refuses the ${label} with INVALID_FIELD / 400`, async () => {
258+
const { p, aggregate } = makeProtocol();
259+
const err: any = await p.findData({
260+
object: 'sys_metadata',
261+
query: { groupBy, aggregations: [{ function: 'count', alias: 'n' }] },
262+
}).catch((e) => e);
263+
264+
expect(err).toBeInstanceOf(Error);
265+
expect(err.code).toBe('INVALID_FIELD');
266+
expect(err.status).toBe(400);
267+
expect(err.param).toBe('groupBy');
268+
expect(err.field).toBe('metadata');
269+
expect(err.message).toContain(position);
270+
expect(aggregate).not.toHaveBeenCalled();
271+
});
272+
}
273+
274+
it('refuses it on sys_metadata_history too', async () => {
275+
const { p, aggregate } = makeProtocol();
276+
const err: any = await p.findData({
277+
object: 'sys_metadata_history',
278+
query: { groupBy: ['metadata'], aggregations: [{ function: 'count', alias: 'n' }] },
279+
}).catch((e) => e);
280+
expect(err.code).toBe('INVALID_FIELD');
281+
expect(err.status).toBe(400);
282+
expect(aggregate).not.toHaveBeenCalled();
283+
});
284+
285+
it('still serves a grouping by any other column, and the same grouping on another object', async () => {
286+
const { p, aggregate } = makeProtocol();
287+
const byType: any = await p.findData({
288+
object: 'sys_metadata',
289+
query: { groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n' }] },
290+
});
291+
expect(byType.records).toEqual([{ type: 'datasource', n: 3 }]);
292+
293+
await p.findData({
294+
object: 'note',
295+
query: { groupBy: ['metadata'], aggregations: [{ function: 'count', alias: 'n' }] },
296+
});
297+
expect(aggregate).toHaveBeenCalledTimes(2);
298+
});
299+
});
300+
301+
describe('[#21086] fails closed on a body it cannot judge', () => {
302+
it('withholds a body whose row carries no `type`', async () => {
303+
const { p } = makeProtocol({ sys_metadata: [{ id: 'm_x', metadata: stored(POSTGRES_BODY) }] });
304+
const result: any = await p.findData({ object: 'sys_metadata', query: {} });
305+
expect(result.records[0]).toEqual({ id: 'm_x' });
306+
});
307+
308+
it('withholds an unparseable body of a type that registers a redactor, and serves one of a type that does not', async () => {
309+
const { p } = makeProtocol({
310+
sys_metadata: [
311+
{ id: 'm_bad_ds', type: 'datasource', metadata: '{"config":{"password":"stored-inline-password"' },
312+
{ id: 'm_bad_view', type: 'view', metadata: '{"name":' },
313+
],
314+
});
315+
const result: any = await p.findData({ object: 'sys_metadata', query: {} });
316+
expect(byId(result.records, 'm_bad_ds')).toEqual({ id: 'm_bad_ds', type: 'datasource' });
317+
expect(byId(result.records, 'm_bad_view').metadata).toBe('{"name":');
318+
});
319+
320+
it('serves an already-parsed body in the shape it arrived in, redacted', async () => {
321+
const { p } = makeProtocol({ sys_metadata: [{ id: 'm_obj', type: 'datasource', metadata: POSTGRES_BODY }] });
322+
const result: any = await p.findData({ object: 'sys_metadata', query: {} });
323+
expectNoSecret(result.records);
324+
expect(typeof result.records[0].metadata).toBe('object');
325+
expect(result.records[0].metadata.config.poolSize).toBe(4);
326+
});
327+
});
328+
329+
describe('[#21086] the door follows the redactor registry, not a datasource rule of its own', () => {
330+
const previous = getMetadataTypeRedactor('view');
331+
afterEach(() => {
332+
registerMetadataTypeRedactor('view', previous ?? ((item) => ({ item, redactedKeys: [] })));
333+
});
334+
335+
it('applies whatever redactor the type registers', async () => {
336+
registerMetadataTypeRedactor('view', (item) => {
337+
const { config: _config, ...rest } = item;
338+
return { item: rest, redactedKeys: ['config.password'] };
339+
});
340+
const { p } = makeProtocol();
341+
const result: any = await p.findData({ object: 'sys_metadata', query: { type: 'view' } });
342+
expect(JSON.parse(result.records[0].metadata)).toEqual({ name: 'all_tasks', label: 'All Tasks', type: 'grid' });
343+
});
344+
});

0 commit comments

Comments
 (0)