@happyvertical/smrt-svelte 0.45.3 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-level data-surface conformance gate (#2450).
|
|
3
|
+
*
|
|
4
|
+
* This fixture deliberately composes the shipped registry, browser/server
|
|
5
|
+
* bridge, principal tools, action adapter, ContentList descriptor, and report
|
|
6
|
+
* adapter. The database and generated REST handler are real; only the bridge
|
|
7
|
+
* transport is an in-process test double.
|
|
8
|
+
*/
|
|
9
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
10
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
11
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
12
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
13
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
14
|
+
};
|
|
15
|
+
import { createDataSurfaceTools, DATA_DISCOVER_TOOL_SLUG, DATA_QUERY_TOOL_SLUG, DataSurfaceDeadlineError, DataSurfaceQueryError, DataSurfaceRequestError, executeAsPrincipal, } from '@happyvertical/smrt-agents';
|
|
16
|
+
import { createDataSurfaceActionAdapter, InMemoryDataSurfaceActionStateStore, } from '@happyvertical/smrt-agents/server';
|
|
17
|
+
import { createDataSurfaceCommandBridge, } from '@happyvertical/smrt-chat/data-surface-bridge';
|
|
18
|
+
import { buildContentListSurfaceDescriptor, CONTENT_LIST_SCHEMA_VERSION, ContentList, } from '@happyvertical/smrt-content/svelte';
|
|
19
|
+
import { APIGenerator, field, getTestDatabase, SmrtCollection, SmrtObject, smrt, } from '@happyvertical/smrt-core';
|
|
20
|
+
import { buildReportAdapterDescriptor, groupBy, queryReportMaterializedRows, report, } from '@happyvertical/smrt-reports';
|
|
21
|
+
import { disableTenancy, enableTenancy, withTenant, } from '@happyvertical/smrt-tenancy';
|
|
22
|
+
import { createDataSurfaceRegistry, createDataTableController, DataTable, } from '@happyvertical/smrt-ui/data';
|
|
23
|
+
import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
|
|
24
|
+
import { registerPermissionDefinitions } from '@happyvertical/smrt-users';
|
|
25
|
+
import { render, screen } from '@testing-library/svelte';
|
|
26
|
+
import userEvent from '@testing-library/user-event';
|
|
27
|
+
import { tick } from 'svelte';
|
|
28
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
29
|
+
let SurfaceConformanceRecord = class SurfaceConformanceRecord extends SmrtObject {
|
|
30
|
+
tenantId = '';
|
|
31
|
+
name = '';
|
|
32
|
+
status = 'active';
|
|
33
|
+
secret = '';
|
|
34
|
+
constructor(options = {}) {
|
|
35
|
+
super(options);
|
|
36
|
+
if (options.tenantId !== undefined)
|
|
37
|
+
this.tenantId = options.tenantId;
|
|
38
|
+
if (options.name !== undefined)
|
|
39
|
+
this.name = options.name;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
__decorate([
|
|
43
|
+
field({ type: 'text' })
|
|
44
|
+
], SurfaceConformanceRecord.prototype, "tenantId", void 0);
|
|
45
|
+
__decorate([
|
|
46
|
+
field({ type: 'text' })
|
|
47
|
+
], SurfaceConformanceRecord.prototype, "name", void 0);
|
|
48
|
+
__decorate([
|
|
49
|
+
field({ type: 'text' })
|
|
50
|
+
], SurfaceConformanceRecord.prototype, "status", void 0);
|
|
51
|
+
__decorate([
|
|
52
|
+
field({ type: 'text', sensitive: true })
|
|
53
|
+
], SurfaceConformanceRecord.prototype, "secret", void 0);
|
|
54
|
+
SurfaceConformanceRecord = __decorate([
|
|
55
|
+
smrt({
|
|
56
|
+
api: { include: ['list', 'get', 'update'] },
|
|
57
|
+
tenantScoped: { mode: 'required' },
|
|
58
|
+
})
|
|
59
|
+
], SurfaceConformanceRecord);
|
|
60
|
+
class SurfaceConformanceRecordCollection extends SmrtCollection {
|
|
61
|
+
static _itemClass = SurfaceConformanceRecord;
|
|
62
|
+
}
|
|
63
|
+
let SurfaceConformanceReport = class SurfaceConformanceReport extends SmrtObject {
|
|
64
|
+
name = '';
|
|
65
|
+
};
|
|
66
|
+
__decorate([
|
|
67
|
+
field({ type: 'text' }),
|
|
68
|
+
groupBy('name')
|
|
69
|
+
], SurfaceConformanceReport.prototype, "name", void 0);
|
|
70
|
+
SurfaceConformanceReport = __decorate([
|
|
71
|
+
smrt({ tenantScoped: { mode: 'required' } }),
|
|
72
|
+
report({ source: SurfaceConformanceRecord })
|
|
73
|
+
], SurfaceConformanceReport);
|
|
74
|
+
const identity = {
|
|
75
|
+
surfaceId: 'surface-conformance-records',
|
|
76
|
+
kind: 'table',
|
|
77
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
78
|
+
};
|
|
79
|
+
const descriptor = {
|
|
80
|
+
version: 1,
|
|
81
|
+
identity,
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
label: 'Records',
|
|
84
|
+
rowKey: 'id',
|
|
85
|
+
columns: [
|
|
86
|
+
{
|
|
87
|
+
id: 'id',
|
|
88
|
+
label: 'ID',
|
|
89
|
+
capabilities: ['read', 'project'],
|
|
90
|
+
role: 'row-key',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'name',
|
|
94
|
+
label: 'Name',
|
|
95
|
+
capabilities: ['read', 'search', 'filter', 'sort', 'project'],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'status',
|
|
99
|
+
label: 'Status',
|
|
100
|
+
capabilities: ['read', 'filter', 'sort', 'project'],
|
|
101
|
+
},
|
|
102
|
+
{ id: 'secret', label: 'Secret', sensitivity: 'secret', capabilities: [] },
|
|
103
|
+
],
|
|
104
|
+
query: {
|
|
105
|
+
modes: ['rows', 'count'],
|
|
106
|
+
projectableColumnIds: ['id', 'name', 'status'],
|
|
107
|
+
searchableColumnIds: ['name'],
|
|
108
|
+
filterableColumnIds: ['name', 'status'],
|
|
109
|
+
sortableColumnIds: ['name', 'status'],
|
|
110
|
+
},
|
|
111
|
+
controls: [
|
|
112
|
+
{ id: 'set-search', label: 'Search' },
|
|
113
|
+
{ id: 'toggle-sorting', label: 'Sort' },
|
|
114
|
+
{ id: 'set-page', label: 'Page' },
|
|
115
|
+
],
|
|
116
|
+
actions: [
|
|
117
|
+
{
|
|
118
|
+
id: 'archive',
|
|
119
|
+
label: 'Archive',
|
|
120
|
+
sensitivity: 'sensitive',
|
|
121
|
+
selectionScopes: ['explicit-ids'],
|
|
122
|
+
requiresConfirmation: true,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
limits: { maxQueryRows: 2, maxQueryBytes: 20_000, maxSelectionSize: 10 },
|
|
126
|
+
};
|
|
127
|
+
// A mounted table can only expose columns that its current principal may
|
|
128
|
+
// render. The server descriptor retains the secret field for redaction tests;
|
|
129
|
+
// the mounted descriptor proves the UI contract cannot accidentally render it.
|
|
130
|
+
const mountedDescriptor = {
|
|
131
|
+
...descriptor,
|
|
132
|
+
columns: descriptor.columns
|
|
133
|
+
.filter((column) => column.id !== 'secret')
|
|
134
|
+
.map(({ role: _role, ...column }) => column),
|
|
135
|
+
query: {
|
|
136
|
+
modes: descriptor.query.modes,
|
|
137
|
+
projectableColumnIds: descriptor.query.projectableColumnIds,
|
|
138
|
+
},
|
|
139
|
+
controls: [
|
|
140
|
+
...descriptor.controls,
|
|
141
|
+
{ id: 'set-filters', label: 'Filter' },
|
|
142
|
+
{ id: 'set-column-order', label: 'Columns' },
|
|
143
|
+
{ id: 'set-column-visibility', label: 'Column visibility' },
|
|
144
|
+
{ id: 'set-selected-rows', label: 'Selection' },
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
function bridgeTransport() {
|
|
148
|
+
const messages = [];
|
|
149
|
+
const listeners = new Set();
|
|
150
|
+
const statuses = new Set();
|
|
151
|
+
return {
|
|
152
|
+
messages,
|
|
153
|
+
send: vi.fn((message) => {
|
|
154
|
+
messages.push(message);
|
|
155
|
+
}),
|
|
156
|
+
subscribe(listener) {
|
|
157
|
+
listeners.add(listener);
|
|
158
|
+
return () => listeners.delete(listener);
|
|
159
|
+
},
|
|
160
|
+
subscribeStatus(listener) {
|
|
161
|
+
statuses.add(listener);
|
|
162
|
+
return () => statuses.delete(listener);
|
|
163
|
+
},
|
|
164
|
+
receive(message, peer) {
|
|
165
|
+
for (const listener of listeners)
|
|
166
|
+
listener(message, peer);
|
|
167
|
+
},
|
|
168
|
+
status(state) {
|
|
169
|
+
for (const listener of statuses)
|
|
170
|
+
listener(state);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function requiredTool(tools, slug) {
|
|
175
|
+
const tool = tools.find((candidate) => candidate.slug === slug);
|
|
176
|
+
if (!tool)
|
|
177
|
+
throw new Error(`Missing fixture tool: ${slug}`);
|
|
178
|
+
return tool;
|
|
179
|
+
}
|
|
180
|
+
describe('data-surface human/agent parity and security (#2450)', () => {
|
|
181
|
+
let db;
|
|
182
|
+
let records;
|
|
183
|
+
let firstId = '';
|
|
184
|
+
let secondId = '';
|
|
185
|
+
let hiddenId = '';
|
|
186
|
+
let releasePermissions;
|
|
187
|
+
beforeAll(async () => {
|
|
188
|
+
db = await getTestDatabase({
|
|
189
|
+
type: 'sqlite',
|
|
190
|
+
url: ':memory:',
|
|
191
|
+
classes: ['SurfaceConformanceRecord'],
|
|
192
|
+
});
|
|
193
|
+
enableTenancy();
|
|
194
|
+
releasePermissions = registerPermissionDefinitions([
|
|
195
|
+
{ slug: 'records.read' },
|
|
196
|
+
{ slug: 'records.update' },
|
|
197
|
+
]);
|
|
198
|
+
records = await SurfaceConformanceRecordCollection.create({ db });
|
|
199
|
+
const first = await withTenant({ tenantId: 'tenant-a' }, () => records.create({ tenantId: 'tenant-a', name: 'Ada' }));
|
|
200
|
+
const second = await withTenant({ tenantId: 'tenant-a' }, () => records.create({ tenantId: 'tenant-a', name: 'Grace' }));
|
|
201
|
+
const hidden = await withTenant({ tenantId: 'tenant-b' }, () => records.create({ tenantId: 'tenant-b', name: 'Hidden tenant' }));
|
|
202
|
+
firstId = String(first.id);
|
|
203
|
+
secondId = String(second.id);
|
|
204
|
+
hiddenId = String(hidden.id);
|
|
205
|
+
await withTenant({ tenantId: 'tenant-a' }, async () => {
|
|
206
|
+
first.secret = 'sensitive-sentinel-tenant-a';
|
|
207
|
+
second.secret = 'sensitive-sentinel-tenant-a-2';
|
|
208
|
+
await first.save();
|
|
209
|
+
await second.save();
|
|
210
|
+
});
|
|
211
|
+
await withTenant({ tenantId: 'tenant-b' }, async () => {
|
|
212
|
+
hidden.secret = 'sensitive-sentinel-tenant-b';
|
|
213
|
+
await hidden.save();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
afterAll(async () => {
|
|
217
|
+
disableTenancy();
|
|
218
|
+
releasePermissions?.();
|
|
219
|
+
await db?.close?.();
|
|
220
|
+
});
|
|
221
|
+
function surface(options = {}) {
|
|
222
|
+
return {
|
|
223
|
+
id: 'records',
|
|
224
|
+
collection: 'records',
|
|
225
|
+
schema: {
|
|
226
|
+
version: 1,
|
|
227
|
+
identityField: 'id',
|
|
228
|
+
fields: [
|
|
229
|
+
{ id: 'id', type: 'string', projectable: true, sortable: true },
|
|
230
|
+
{
|
|
231
|
+
id: 'name',
|
|
232
|
+
type: 'string',
|
|
233
|
+
projectable: true,
|
|
234
|
+
sortable: true,
|
|
235
|
+
filterOperators: ['eq'],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'status',
|
|
239
|
+
type: 'string',
|
|
240
|
+
projectable: true,
|
|
241
|
+
sortable: true,
|
|
242
|
+
filterOperators: ['eq'],
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'tenantId',
|
|
246
|
+
type: 'string',
|
|
247
|
+
projectable: true,
|
|
248
|
+
sensitive: true,
|
|
249
|
+
},
|
|
250
|
+
{ id: 'secret', type: 'string', projectable: true, sensitive: true },
|
|
251
|
+
],
|
|
252
|
+
defaultPageLimit: 2,
|
|
253
|
+
maxPageLimit: 2,
|
|
254
|
+
maxResultBytes: 20_000,
|
|
255
|
+
supports: { cursorPagination: true },
|
|
256
|
+
},
|
|
257
|
+
execute: async (_surface, request, context) => {
|
|
258
|
+
const all = await records.list();
|
|
259
|
+
const scoped = all
|
|
260
|
+
.filter((row) => row.tenantId === context.principal.tenantId)
|
|
261
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
262
|
+
const cursorPage = request.page?.kind === 'cursor' ? request.page : undefined;
|
|
263
|
+
const offset = request.page?.kind === 'offset'
|
|
264
|
+
? request.page.offset
|
|
265
|
+
: cursorPage?.after
|
|
266
|
+
? scoped.findIndex((row) => row.id === cursorPage.after) + 1
|
|
267
|
+
: 0;
|
|
268
|
+
const limit = request.page ? request.page.limit : scoped.length;
|
|
269
|
+
const rows = scoped.slice(offset, offset + limit).map((row) => {
|
|
270
|
+
const projection = new Set(request.projection ?? []);
|
|
271
|
+
return {
|
|
272
|
+
id: row.id,
|
|
273
|
+
...(projection.has('name') ? { name: row.name } : {}),
|
|
274
|
+
...(projection.has('status') ? { status: row.status } : {}),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
rows,
|
|
279
|
+
page: cursorPage
|
|
280
|
+
? {
|
|
281
|
+
kind: 'cursor',
|
|
282
|
+
limit,
|
|
283
|
+
hasMore: offset + rows.length < scoped.length,
|
|
284
|
+
...(offset + rows.length < scoped.length && rows.length > 0
|
|
285
|
+
? { nextCursor: String(rows.at(-1)?.id) }
|
|
286
|
+
: {}),
|
|
287
|
+
}
|
|
288
|
+
: {
|
|
289
|
+
kind: 'offset',
|
|
290
|
+
offset,
|
|
291
|
+
limit,
|
|
292
|
+
hasMore: offset + rows.length < scoped.length,
|
|
293
|
+
},
|
|
294
|
+
total: { kind: 'exact', value: scoped.length },
|
|
295
|
+
freshness: {
|
|
296
|
+
state: 'fresh',
|
|
297
|
+
asOf: '2026-08-30T00:00:00.000Z',
|
|
298
|
+
},
|
|
299
|
+
warnings: [],
|
|
300
|
+
truncated: options.truncated ?? false,
|
|
301
|
+
};
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function principalOptions(tenantId, allowedTools) {
|
|
306
|
+
return {
|
|
307
|
+
db,
|
|
308
|
+
principal: {
|
|
309
|
+
runAsUserId: 'surface-user',
|
|
310
|
+
tenantId,
|
|
311
|
+
allowedTools,
|
|
312
|
+
},
|
|
313
|
+
permissions: ['records.read', 'records.update'],
|
|
314
|
+
postgresRls: false,
|
|
315
|
+
audit: () => undefined,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
it('uses the real DB-backed principal query and REST auth boundary', async () => {
|
|
319
|
+
const tools = createDataSurfaceTools({ surfaces: [surface()] });
|
|
320
|
+
const discovered = await executeAsPrincipal(principalOptions('tenant-a', [
|
|
321
|
+
DATA_DISCOVER_TOOL_SLUG,
|
|
322
|
+
DATA_QUERY_TOOL_SLUG,
|
|
323
|
+
]), (run) => requiredTool(tools, DATA_DISCOVER_TOOL_SLUG).execute({
|
|
324
|
+
run,
|
|
325
|
+
args: {},
|
|
326
|
+
db,
|
|
327
|
+
}));
|
|
328
|
+
expect(discovered).toMatchObject([
|
|
329
|
+
{ id: 'records', fields: expect.any(Array) },
|
|
330
|
+
]);
|
|
331
|
+
const fields = discovered[0]
|
|
332
|
+
.fields;
|
|
333
|
+
expect(fields.map((field) => field.id)).toEqual(['id', 'name', 'status']);
|
|
334
|
+
const queried = await executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(tools, DATA_QUERY_TOOL_SLUG).execute({
|
|
335
|
+
run,
|
|
336
|
+
args: {
|
|
337
|
+
surfaceId: 'records',
|
|
338
|
+
request: {
|
|
339
|
+
version: 1,
|
|
340
|
+
requestId: 'principal-page-1',
|
|
341
|
+
mode: 'rows',
|
|
342
|
+
projection: ['id', 'name', 'status'],
|
|
343
|
+
page: { kind: 'offset', offset: 0, limit: 1 },
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
db,
|
|
347
|
+
}));
|
|
348
|
+
expect(queried).toMatchObject({
|
|
349
|
+
rows: [{ id: firstId, name: 'Ada' }],
|
|
350
|
+
total: { kind: 'exact', value: 2 },
|
|
351
|
+
freshness: { state: 'fresh' },
|
|
352
|
+
});
|
|
353
|
+
expect(JSON.stringify(queried)).not.toContain('tenant-b');
|
|
354
|
+
let cursorFailure;
|
|
355
|
+
const cursorTools = createDataSurfaceTools({
|
|
356
|
+
surfaces: [surface()],
|
|
357
|
+
onFailure: (entry) => {
|
|
358
|
+
cursorFailure = entry.error;
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
const cursorQueried = await executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(cursorTools, DATA_QUERY_TOOL_SLUG).execute({
|
|
362
|
+
run,
|
|
363
|
+
args: {
|
|
364
|
+
surfaceId: 'records',
|
|
365
|
+
request: {
|
|
366
|
+
version: 1,
|
|
367
|
+
requestId: 'principal-cursor-1',
|
|
368
|
+
mode: 'rows',
|
|
369
|
+
projection: ['id', 'name'],
|
|
370
|
+
page: { kind: 'cursor', limit: 1 },
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
db,
|
|
374
|
+
}));
|
|
375
|
+
expect(cursorFailure).toBeUndefined();
|
|
376
|
+
expect(cursorQueried).toMatchObject({
|
|
377
|
+
rows: [{ id: firstId }],
|
|
378
|
+
page: { kind: 'cursor', limit: 1, hasMore: true, nextCursor: firstId },
|
|
379
|
+
});
|
|
380
|
+
const bounded = createDataSurfaceTools({
|
|
381
|
+
surfaces: [surface({ truncated: true })],
|
|
382
|
+
});
|
|
383
|
+
const boundedResult = await executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(bounded, DATA_QUERY_TOOL_SLUG).execute({
|
|
384
|
+
run,
|
|
385
|
+
args: {
|
|
386
|
+
surfaceId: 'records',
|
|
387
|
+
request: {
|
|
388
|
+
version: 1,
|
|
389
|
+
requestId: 'principal-truncated',
|
|
390
|
+
mode: 'rows',
|
|
391
|
+
projection: ['id', 'name'],
|
|
392
|
+
page: { kind: 'offset', offset: 0, limit: 1 },
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
db,
|
|
396
|
+
}));
|
|
397
|
+
expect(boundedResult).toMatchObject({ truncated: true, warnings: [] });
|
|
398
|
+
// Sensitive fields are adversarially requested at the principal boundary;
|
|
399
|
+
// visibleSchema rejects them before the executor can read or serialize a
|
|
400
|
+
// sentinel value.
|
|
401
|
+
await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(tools, DATA_QUERY_TOOL_SLUG).execute({
|
|
402
|
+
run,
|
|
403
|
+
args: {
|
|
404
|
+
surfaceId: 'records',
|
|
405
|
+
request: {
|
|
406
|
+
version: 1,
|
|
407
|
+
requestId: 'principal-sensitive-probe',
|
|
408
|
+
mode: 'rows',
|
|
409
|
+
projection: ['id', 'secret'],
|
|
410
|
+
page: { kind: 'offset', offset: 0, limit: 1 },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
db,
|
|
414
|
+
}))).rejects.toBeInstanceOf(DataSurfaceRequestError);
|
|
415
|
+
// Exercise the result-byte gate with a deliberately oversized executor
|
|
416
|
+
// payload. A safe projection alone would not prove this bound is active.
|
|
417
|
+
const oversized = surface();
|
|
418
|
+
oversized.schema = { ...oversized.schema, maxResultBytes: 120 };
|
|
419
|
+
oversized.execute = async () => ({
|
|
420
|
+
rows: [{ id: firstId, name: 'x'.repeat(500) }],
|
|
421
|
+
total: { kind: 'exact', value: 1 },
|
|
422
|
+
page: { kind: 'offset', offset: 0, limit: 1, hasMore: false },
|
|
423
|
+
freshness: { state: 'fresh' },
|
|
424
|
+
warnings: [],
|
|
425
|
+
truncated: false,
|
|
426
|
+
});
|
|
427
|
+
await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => {
|
|
428
|
+
const oversizedTools = createDataSurfaceTools({
|
|
429
|
+
surfaces: [oversized],
|
|
430
|
+
});
|
|
431
|
+
return requiredTool(oversizedTools, DATA_QUERY_TOOL_SLUG).execute({
|
|
432
|
+
run,
|
|
433
|
+
args: {
|
|
434
|
+
surfaceId: 'records',
|
|
435
|
+
request: {
|
|
436
|
+
version: 1,
|
|
437
|
+
requestId: 'principal-byte-bound',
|
|
438
|
+
mode: 'rows',
|
|
439
|
+
projection: ['id', 'name'],
|
|
440
|
+
page: { kind: 'offset', offset: 0, limit: 1 },
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
db,
|
|
444
|
+
});
|
|
445
|
+
})).rejects.toBeInstanceOf(DataSurfaceQueryError);
|
|
446
|
+
// The executor must not be able to return more rows than the requested
|
|
447
|
+
// bounded page, even when the caller asks for a valid limit.
|
|
448
|
+
const overRows = surface();
|
|
449
|
+
overRows.execute = async () => [
|
|
450
|
+
{ id: firstId, name: 'Ada' },
|
|
451
|
+
{ id: secondId, name: 'Grace' },
|
|
452
|
+
{ id: hiddenId, name: 'Hidden tenant' },
|
|
453
|
+
];
|
|
454
|
+
await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => {
|
|
455
|
+
const overRowsTools = createDataSurfaceTools({
|
|
456
|
+
surfaces: [overRows],
|
|
457
|
+
});
|
|
458
|
+
return requiredTool(overRowsTools, DATA_QUERY_TOOL_SLUG).execute({
|
|
459
|
+
run,
|
|
460
|
+
args: {
|
|
461
|
+
surfaceId: 'records',
|
|
462
|
+
request: {
|
|
463
|
+
version: 1,
|
|
464
|
+
requestId: 'principal-row-bound',
|
|
465
|
+
mode: 'rows',
|
|
466
|
+
projection: ['id', 'name'],
|
|
467
|
+
page: { kind: 'offset', offset: 0, limit: 2 },
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
db,
|
|
471
|
+
});
|
|
472
|
+
})).rejects.toBeInstanceOf(DataSurfaceQueryError);
|
|
473
|
+
let aborted = false;
|
|
474
|
+
const slow = surface({});
|
|
475
|
+
slow.execute = async (_surface, _request, context) => {
|
|
476
|
+
context.signal.addEventListener('abort', () => {
|
|
477
|
+
aborted = true;
|
|
478
|
+
}, { once: true });
|
|
479
|
+
return new Promise(() => undefined);
|
|
480
|
+
};
|
|
481
|
+
const slowTools = createDataSurfaceTools({
|
|
482
|
+
surfaces: [slow],
|
|
483
|
+
deadlineMs: 1,
|
|
484
|
+
});
|
|
485
|
+
await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(slowTools, DATA_QUERY_TOOL_SLUG).execute({
|
|
486
|
+
run,
|
|
487
|
+
args: {
|
|
488
|
+
surfaceId: 'records',
|
|
489
|
+
request: {
|
|
490
|
+
version: 1,
|
|
491
|
+
requestId: 'principal-timeout',
|
|
492
|
+
mode: 'rows',
|
|
493
|
+
projection: ['id', 'name'],
|
|
494
|
+
page: { kind: 'offset', offset: 0, limit: 1 },
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
db,
|
|
498
|
+
}))).rejects.toBeInstanceOf(DataSurfaceDeadlineError);
|
|
499
|
+
expect(aborted).toBe(true);
|
|
500
|
+
// Model the production request boundary: authentication resolves a
|
|
501
|
+
// trusted session first, then the server binds both the principal and its
|
|
502
|
+
// tenant before entering the generated REST handler. The handler itself
|
|
503
|
+
// still runs its configured auth middleware, so a request cannot swap
|
|
504
|
+
// credentials after the session context has been selected.
|
|
505
|
+
const fixtureSessions = new Map([
|
|
506
|
+
['Bearer fixture-user', { userId: 'surface-user', tenantId: 'tenant-a' }],
|
|
507
|
+
[
|
|
508
|
+
'Bearer fixture-user-b',
|
|
509
|
+
{ userId: 'surface-user-b', tenantId: 'tenant-b' },
|
|
510
|
+
],
|
|
511
|
+
]);
|
|
512
|
+
const handleRestRequest = async (request) => {
|
|
513
|
+
const token = request.headers.get('authorization');
|
|
514
|
+
if (!token)
|
|
515
|
+
return new Response('auth required', { status: 401 });
|
|
516
|
+
const session = fixtureSessions.get(token);
|
|
517
|
+
if (!session)
|
|
518
|
+
return new Response('forbidden', { status: 403 });
|
|
519
|
+
const api = new APIGenerator({
|
|
520
|
+
basePath: '/api/v1',
|
|
521
|
+
authMiddleware: () => async (authenticatedRequest) => {
|
|
522
|
+
if (authenticatedRequest.headers.get('authorization') !== token) {
|
|
523
|
+
return new Response('forbidden', { status: 403 });
|
|
524
|
+
}
|
|
525
|
+
return authenticatedRequest;
|
|
526
|
+
},
|
|
527
|
+
}, {
|
|
528
|
+
db,
|
|
529
|
+
user: { id: session.userId },
|
|
530
|
+
permissions: ['records.read', 'records.update'],
|
|
531
|
+
});
|
|
532
|
+
api.registerCollection('surfaceconformancerecords', records);
|
|
533
|
+
return withTenant({ tenantId: session.tenantId }, () => api.generateHandler()(request));
|
|
534
|
+
};
|
|
535
|
+
expect((await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords'))).status).toBe(401);
|
|
536
|
+
expect((await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords', {
|
|
537
|
+
headers: { authorization: 'Bearer other' },
|
|
538
|
+
}))).status).toBe(403);
|
|
539
|
+
const restResponse = await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords', {
|
|
540
|
+
headers: { authorization: 'Bearer fixture-user' },
|
|
541
|
+
}));
|
|
542
|
+
expect(restResponse.status).toBe(200);
|
|
543
|
+
const restBody = await restResponse.json();
|
|
544
|
+
expect(JSON.stringify(restBody)).toContain(firstId);
|
|
545
|
+
expect(JSON.stringify(restBody)).not.toContain(hiddenId);
|
|
546
|
+
expect(JSON.stringify(restBody)).not.toContain('Hidden tenant');
|
|
547
|
+
expect(JSON.stringify(restBody)).not.toContain('sensitive-sentinel');
|
|
548
|
+
const otherTenantResponse = await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords', {
|
|
549
|
+
headers: { authorization: 'Bearer fixture-user-b' },
|
|
550
|
+
}));
|
|
551
|
+
expect(otherTenantResponse.status).toBe(200);
|
|
552
|
+
const otherTenantBody = await otherTenantResponse.json();
|
|
553
|
+
expect(JSON.stringify(otherTenantBody)).toContain(hiddenId);
|
|
554
|
+
expect(JSON.stringify(otherTenantBody)).not.toContain(firstId);
|
|
555
|
+
expect(JSON.stringify(otherTenantBody)).not.toContain('Ada');
|
|
556
|
+
expect(JSON.stringify(otherTenantBody)).not.toContain('sensitive-sentinel');
|
|
557
|
+
});
|
|
558
|
+
it('keeps human and browser command snapshots identical and rejects bridge abuse', async () => {
|
|
559
|
+
const registry = createDataSurfaceRegistry();
|
|
560
|
+
const controller = createDataTableController({
|
|
561
|
+
columnIds: ['id', 'name', 'status'],
|
|
562
|
+
});
|
|
563
|
+
const browserIdentity = {
|
|
564
|
+
...identity,
|
|
565
|
+
surfaceId: 'surface-conformance-browser',
|
|
566
|
+
};
|
|
567
|
+
const browserController = createDataTableController({
|
|
568
|
+
columnIds: ['id', 'name', 'status'],
|
|
569
|
+
});
|
|
570
|
+
const { container } = render(DataTable, {
|
|
571
|
+
props: {
|
|
572
|
+
data: [
|
|
573
|
+
{ id: firstId, name: 'Ada', status: 'active' },
|
|
574
|
+
{ id: secondId, name: 'Grace', status: 'active' },
|
|
575
|
+
],
|
|
576
|
+
columns: [
|
|
577
|
+
{ id: 'id', label: 'ID', accessor: 'id' },
|
|
578
|
+
{
|
|
579
|
+
id: 'name',
|
|
580
|
+
label: 'Name',
|
|
581
|
+
accessor: 'name',
|
|
582
|
+
sortable: true,
|
|
583
|
+
filterable: true,
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
id: 'status',
|
|
587
|
+
label: 'Status',
|
|
588
|
+
accessor: 'status',
|
|
589
|
+
sortable: true,
|
|
590
|
+
filterable: true,
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
rowKey: 'id',
|
|
594
|
+
sortable: true,
|
|
595
|
+
selectable: true,
|
|
596
|
+
controller,
|
|
597
|
+
caption: 'Records',
|
|
598
|
+
dataSurface: { registry, descriptor: mountedDescriptor },
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
render(DataTable, {
|
|
602
|
+
props: {
|
|
603
|
+
data: [
|
|
604
|
+
{ id: firstId, name: 'Ada', status: 'active' },
|
|
605
|
+
{ id: secondId, name: 'Grace', status: 'active' },
|
|
606
|
+
],
|
|
607
|
+
columns: [
|
|
608
|
+
{ id: 'id', label: 'ID', accessor: 'id' },
|
|
609
|
+
{
|
|
610
|
+
id: 'name',
|
|
611
|
+
label: 'Name',
|
|
612
|
+
accessor: 'name',
|
|
613
|
+
sortable: true,
|
|
614
|
+
filterable: true,
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
id: 'status',
|
|
618
|
+
label: 'Status',
|
|
619
|
+
accessor: 'status',
|
|
620
|
+
sortable: true,
|
|
621
|
+
filterable: true,
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
rowKey: 'id',
|
|
625
|
+
sortable: true,
|
|
626
|
+
selectable: true,
|
|
627
|
+
controller: browserController,
|
|
628
|
+
caption: 'Records browser mirror',
|
|
629
|
+
dataSurface: {
|
|
630
|
+
registry,
|
|
631
|
+
descriptor: { ...mountedDescriptor, identity: browserIdentity },
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
await tick();
|
|
636
|
+
await vi.waitFor(() => {
|
|
637
|
+
expect(registry.inspect(identity)).toBeDefined();
|
|
638
|
+
expect(registry.inspect(browserIdentity)).toBeDefined();
|
|
639
|
+
});
|
|
640
|
+
const sortButton = container.querySelector('button[aria-label="Sort Name ascending"]');
|
|
641
|
+
if (!(sortButton instanceof HTMLElement)) {
|
|
642
|
+
throw new Error('Mounted human table did not render its sort button');
|
|
643
|
+
}
|
|
644
|
+
await userEvent.click(sortButton);
|
|
645
|
+
const sortedHuman = registry.inspect(identity);
|
|
646
|
+
expect(sortedHuman?.state).toMatchObject({
|
|
647
|
+
table: { state: { sorting: [{ columnId: 'name', direction: 'asc' }] } },
|
|
648
|
+
});
|
|
649
|
+
await expectNoA11yViolations(container);
|
|
650
|
+
const browserSort = await registry.execute({
|
|
651
|
+
version: 1,
|
|
652
|
+
commandId: 'browser-sort',
|
|
653
|
+
identity: browserIdentity,
|
|
654
|
+
expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
|
|
655
|
+
controlId: 'toggle-sorting',
|
|
656
|
+
payload: { columnId: 'name' },
|
|
657
|
+
});
|
|
658
|
+
expect(browserSort).toMatchObject({ ok: true });
|
|
659
|
+
expect(controller.snapshot()).toEqual(browserController.snapshot());
|
|
660
|
+
// Apply each remaining command once through the mounted human controller
|
|
661
|
+
// and once through the mounted browser registry. Separate controllers make
|
|
662
|
+
// this a real parity check rather than a second no-op against one state.
|
|
663
|
+
controller.dispatch({
|
|
664
|
+
type: 'setFilters',
|
|
665
|
+
filters: [{ columnId: 'name', operator: 'equals', value: 'Ada' }],
|
|
666
|
+
});
|
|
667
|
+
const browserFilter = await registry.execute({
|
|
668
|
+
version: 1,
|
|
669
|
+
commandId: 'browser-filter',
|
|
670
|
+
identity: browserIdentity,
|
|
671
|
+
expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
|
|
672
|
+
controlId: 'set-filters',
|
|
673
|
+
payload: {
|
|
674
|
+
filters: [{ columnId: 'name', operator: 'equals', value: 'Ada' }],
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
expect(browserFilter).toMatchObject({ ok: true });
|
|
678
|
+
expect(controller.snapshot()).toEqual(browserController.snapshot());
|
|
679
|
+
controller.dispatch({
|
|
680
|
+
type: 'setColumnOrder',
|
|
681
|
+
columnIds: ['status', 'name', 'id'],
|
|
682
|
+
});
|
|
683
|
+
const browserColumns = await registry.execute({
|
|
684
|
+
version: 1,
|
|
685
|
+
commandId: 'browser-columns',
|
|
686
|
+
identity: browserIdentity,
|
|
687
|
+
expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
|
|
688
|
+
controlId: 'set-column-order',
|
|
689
|
+
payload: { columnIds: ['status', 'name', 'id'] },
|
|
690
|
+
});
|
|
691
|
+
expect(browserColumns).toMatchObject({ ok: true });
|
|
692
|
+
expect(controller.snapshot()).toEqual(browserController.snapshot());
|
|
693
|
+
controller.dispatch({ type: 'setPage', page: 2 });
|
|
694
|
+
const browserPage = await registry.execute({
|
|
695
|
+
version: 1,
|
|
696
|
+
commandId: 'browser-page',
|
|
697
|
+
identity: browserIdentity,
|
|
698
|
+
expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
|
|
699
|
+
controlId: 'set-page',
|
|
700
|
+
payload: { page: 2 },
|
|
701
|
+
});
|
|
702
|
+
expect(browserPage).toMatchObject({ ok: true });
|
|
703
|
+
expect(controller.snapshot()).toEqual(browserController.snapshot());
|
|
704
|
+
const visibleColumns = [
|
|
705
|
+
{ columnId: 'id', visible: false },
|
|
706
|
+
{ columnId: 'name', visible: true },
|
|
707
|
+
{ columnId: 'status', visible: true },
|
|
708
|
+
];
|
|
709
|
+
controller.dispatch({
|
|
710
|
+
type: 'setColumnVisibility',
|
|
711
|
+
columns: visibleColumns,
|
|
712
|
+
});
|
|
713
|
+
const browserVisibility = await registry.execute({
|
|
714
|
+
version: 1,
|
|
715
|
+
commandId: 'browser-visibility',
|
|
716
|
+
identity: browserIdentity,
|
|
717
|
+
expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
|
|
718
|
+
controlId: 'set-column-visibility',
|
|
719
|
+
payload: { columns: visibleColumns },
|
|
720
|
+
});
|
|
721
|
+
expect(browserVisibility).toMatchObject({ ok: true });
|
|
722
|
+
expect(controller.snapshot()).toEqual(browserController.snapshot());
|
|
723
|
+
controller.dispatch({
|
|
724
|
+
type: 'toggleRowSelection',
|
|
725
|
+
rowId: firstId,
|
|
726
|
+
});
|
|
727
|
+
const browserSelection = await registry.execute({
|
|
728
|
+
version: 1,
|
|
729
|
+
commandId: 'browser-selection',
|
|
730
|
+
identity: browserIdentity,
|
|
731
|
+
expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
|
|
732
|
+
controlId: 'set-selected-rows',
|
|
733
|
+
payload: { rowIds: [firstId] },
|
|
734
|
+
});
|
|
735
|
+
expect(browserSelection).toMatchObject({ ok: true });
|
|
736
|
+
expect(controller.snapshot()).toEqual(browserController.snapshot());
|
|
737
|
+
expect(browserSelection.snapshot?.selection).toEqual({
|
|
738
|
+
scope: 'explicit-ids',
|
|
739
|
+
rowIds: [firstId],
|
|
740
|
+
});
|
|
741
|
+
const human = registry.inspect(identity);
|
|
742
|
+
await expect(registry.execute({
|
|
743
|
+
version: 1,
|
|
744
|
+
commandId: 'stale-command',
|
|
745
|
+
identity,
|
|
746
|
+
expectedRevision: (human?.revision ?? 0) - 1,
|
|
747
|
+
controlId: 'toggle-sorting',
|
|
748
|
+
payload: { columnId: 'status' },
|
|
749
|
+
})).resolves.toMatchObject({ ok: false, reason: 'stale_revision' });
|
|
750
|
+
const replayCommand = {
|
|
751
|
+
version: 1,
|
|
752
|
+
commandId: 'replay-command',
|
|
753
|
+
identity,
|
|
754
|
+
expectedRevision: human?.revision ?? 0,
|
|
755
|
+
controlId: 'toggle-sorting',
|
|
756
|
+
payload: { columnId: 'status' },
|
|
757
|
+
};
|
|
758
|
+
const replayed = await registry.execute(replayCommand);
|
|
759
|
+
expect(replayed).toMatchObject({
|
|
760
|
+
ok: true,
|
|
761
|
+
revision: (human?.revision ?? 0) + 1,
|
|
762
|
+
});
|
|
763
|
+
await expect(registry.execute(replayCommand)).resolves.toEqual(replayed);
|
|
764
|
+
await expect(registry.execute({
|
|
765
|
+
...replayCommand,
|
|
766
|
+
payload: { columnId: 'name' },
|
|
767
|
+
})).resolves.toMatchObject({ ok: false, reason: 'idempotency_conflict' });
|
|
768
|
+
await expect(registry.execute({
|
|
769
|
+
...replayCommand,
|
|
770
|
+
commandId: 'absent-command',
|
|
771
|
+
identity: { ...identity, surfaceId: 'surface-that-is-not-mounted' },
|
|
772
|
+
expectedRevision: 0,
|
|
773
|
+
})).resolves.toMatchObject({ ok: false, reason: 'not_found' });
|
|
774
|
+
const ackLink = bridgeTransport();
|
|
775
|
+
ackLink.send.mockImplementation((message) => {
|
|
776
|
+
ackLink.messages.push(message);
|
|
777
|
+
if (message.type !== 'data-surface.command')
|
|
778
|
+
return;
|
|
779
|
+
void registry
|
|
780
|
+
.execute({
|
|
781
|
+
version: 1,
|
|
782
|
+
commandId: message.commandId,
|
|
783
|
+
identity: message.identity,
|
|
784
|
+
expectedRevision: message.expectedRevision,
|
|
785
|
+
controlId: message.controlId,
|
|
786
|
+
...(message.payload === undefined
|
|
787
|
+
? {}
|
|
788
|
+
: { payload: message.payload }),
|
|
789
|
+
})
|
|
790
|
+
.then((result) => {
|
|
791
|
+
ackLink.receive({
|
|
792
|
+
type: 'data-surface.ack',
|
|
793
|
+
version: 1,
|
|
794
|
+
commandId: message.commandId,
|
|
795
|
+
sessionId: message.sessionId,
|
|
796
|
+
source: 'browser-1',
|
|
797
|
+
expiresAt: message.expiresAt,
|
|
798
|
+
identity: message.identity,
|
|
799
|
+
expectedRevision: message.expectedRevision,
|
|
800
|
+
ok: result.ok,
|
|
801
|
+
...(result.revision === undefined
|
|
802
|
+
? {}
|
|
803
|
+
: { revision: result.revision }),
|
|
804
|
+
...(result.snapshot === undefined
|
|
805
|
+
? {}
|
|
806
|
+
: { snapshot: result.snapshot }),
|
|
807
|
+
...(result.reason === undefined ? {} : { reason: result.reason }),
|
|
808
|
+
}, { sessionId: 'session-1', source: 'browser-1' });
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
const ackBridge = createDataSurfaceCommandBridge({
|
|
812
|
+
transport: ackLink,
|
|
813
|
+
sessionId: 'session-1',
|
|
814
|
+
source: 'server-1',
|
|
815
|
+
peerSource: 'browser-1',
|
|
816
|
+
authorize: () => true,
|
|
817
|
+
now: () => 1_000,
|
|
818
|
+
ttlMs: 1_000,
|
|
819
|
+
timeoutMs: 1_000,
|
|
820
|
+
});
|
|
821
|
+
const browserAck = await ackBridge.send({
|
|
822
|
+
version: 1,
|
|
823
|
+
commandId: 'browser-state-ack',
|
|
824
|
+
identity,
|
|
825
|
+
expectedRevision: replayed.revision ?? 0,
|
|
826
|
+
controlId: 'set-page',
|
|
827
|
+
payload: { page: 2 },
|
|
828
|
+
});
|
|
829
|
+
expect(browserAck).toMatchObject({
|
|
830
|
+
ok: true,
|
|
831
|
+
revision: expect.any(Number),
|
|
832
|
+
});
|
|
833
|
+
expect(browserAck.snapshot).toEqual(registry.inspect(identity));
|
|
834
|
+
ackBridge.dispose();
|
|
835
|
+
// ACKs are accepted only from the bound transport peer and only when
|
|
836
|
+
// every correlation field still describes the pending command. Exercise
|
|
837
|
+
// those checks through the transport, rather than calling bridge internals.
|
|
838
|
+
const adversarialLink = bridgeTransport();
|
|
839
|
+
let bridgeNow = 1_000;
|
|
840
|
+
const adversarial = createDataSurfaceCommandBridge({
|
|
841
|
+
transport: adversarialLink,
|
|
842
|
+
sessionId: 'session-ack',
|
|
843
|
+
source: 'server-ack',
|
|
844
|
+
peerSource: 'browser-ack',
|
|
845
|
+
authorize: () => true,
|
|
846
|
+
now: () => bridgeNow,
|
|
847
|
+
ttlMs: 1_000,
|
|
848
|
+
timeoutMs: 1_000,
|
|
849
|
+
});
|
|
850
|
+
const adversarialPromise = adversarial.send({
|
|
851
|
+
version: 1,
|
|
852
|
+
commandId: 'adversarial-ack',
|
|
853
|
+
identity,
|
|
854
|
+
expectedRevision: replayed.revision ?? 0,
|
|
855
|
+
controlId: 'set-page',
|
|
856
|
+
payload: { page: 3 },
|
|
857
|
+
});
|
|
858
|
+
await vi.waitFor(() => expect(adversarialLink.messages).toHaveLength(1));
|
|
859
|
+
const adversarialRequest = adversarialLink.messages[0];
|
|
860
|
+
if (adversarialRequest?.type !== 'data-surface.command') {
|
|
861
|
+
throw new Error('Expected a pending command request');
|
|
862
|
+
}
|
|
863
|
+
const adversarialAck = {
|
|
864
|
+
type: 'data-surface.ack',
|
|
865
|
+
version: 1,
|
|
866
|
+
commandId: adversarialRequest.commandId,
|
|
867
|
+
sessionId: adversarialRequest.sessionId,
|
|
868
|
+
source: 'browser-ack',
|
|
869
|
+
expiresAt: adversarialRequest.expiresAt,
|
|
870
|
+
identity: adversarialRequest.identity,
|
|
871
|
+
expectedRevision: adversarialRequest.expectedRevision,
|
|
872
|
+
ok: false,
|
|
873
|
+
reason: 'denied',
|
|
874
|
+
};
|
|
875
|
+
// A wrong session or source is rejected at the transport boundary.
|
|
876
|
+
adversarialLink.receive(adversarialAck, {
|
|
877
|
+
sessionId: 'other-session',
|
|
878
|
+
source: 'browser-ack',
|
|
879
|
+
});
|
|
880
|
+
adversarialLink.receive(adversarialAck, {
|
|
881
|
+
sessionId: 'session-ack',
|
|
882
|
+
source: 'other-browser',
|
|
883
|
+
});
|
|
884
|
+
// Correlated peers cannot smuggle a different source, identity, or
|
|
885
|
+
// expected revision into the pending request.
|
|
886
|
+
adversarialLink.receive({ ...adversarialAck, source: 'other-browser' }, { sessionId: 'session-ack', source: 'browser-ack' });
|
|
887
|
+
adversarialLink.receive({
|
|
888
|
+
...adversarialAck,
|
|
889
|
+
identity: { ...identity, surfaceId: 'other-surface' },
|
|
890
|
+
}, { sessionId: 'session-ack', source: 'browser-ack' });
|
|
891
|
+
adversarialLink.receive({
|
|
892
|
+
...adversarialAck,
|
|
893
|
+
identity: {
|
|
894
|
+
...identity,
|
|
895
|
+
subject: { type: 'tenant', id: 'tenant-b' },
|
|
896
|
+
},
|
|
897
|
+
}, { sessionId: 'session-ack', source: 'browser-ack' });
|
|
898
|
+
adversarialLink.receive({
|
|
899
|
+
...adversarialAck,
|
|
900
|
+
expectedRevision: adversarialAck.expectedRevision + 1,
|
|
901
|
+
}, { sessionId: 'session-ack', source: 'browser-ack' });
|
|
902
|
+
// Even a fully correlated ACK is refused after its signed envelope TTL.
|
|
903
|
+
bridgeNow = adversarialRequest.expiresAt + 1;
|
|
904
|
+
adversarialLink.receive(adversarialAck, {
|
|
905
|
+
sessionId: 'session-ack',
|
|
906
|
+
source: 'browser-ack',
|
|
907
|
+
});
|
|
908
|
+
await expect(adversarialPromise).resolves.toMatchObject({
|
|
909
|
+
ok: false,
|
|
910
|
+
reason: 'expired',
|
|
911
|
+
});
|
|
912
|
+
// Replay and an ACK for an absent pending command are harmless no-ops.
|
|
913
|
+
adversarialLink.receive(adversarialAck, {
|
|
914
|
+
sessionId: 'session-ack',
|
|
915
|
+
source: 'browser-ack',
|
|
916
|
+
});
|
|
917
|
+
adversarialLink.receive({ ...adversarialAck, commandId: 'never-pending' }, { sessionId: 'session-ack', source: 'browser-ack' });
|
|
918
|
+
adversarial.dispose();
|
|
919
|
+
const link = bridgeTransport();
|
|
920
|
+
const authenticatedPrincipal = Object.freeze({
|
|
921
|
+
sessionId: 'session-1',
|
|
922
|
+
userId: 'surface-user',
|
|
923
|
+
tenantId: 'tenant-a',
|
|
924
|
+
});
|
|
925
|
+
const browser = createDataSurfaceCommandBridge({
|
|
926
|
+
transport: link,
|
|
927
|
+
sessionId: authenticatedPrincipal.sessionId,
|
|
928
|
+
source: 'server-1',
|
|
929
|
+
peerSource: 'browser-1',
|
|
930
|
+
// Authorization comes from the trusted authenticated session. The
|
|
931
|
+
// command subject is only the requested target and cannot establish
|
|
932
|
+
// authority by itself.
|
|
933
|
+
authorize: async (command) => authenticatedPrincipal.userId === 'surface-user' &&
|
|
934
|
+
command.identity.subject?.type === 'tenant' &&
|
|
935
|
+
command.identity.subject.id === authenticatedPrincipal.tenantId,
|
|
936
|
+
now: () => 1_000,
|
|
937
|
+
ttlMs: 1_000,
|
|
938
|
+
timeoutMs: 100,
|
|
939
|
+
});
|
|
940
|
+
const commandPromise = browser.send({
|
|
941
|
+
version: 1,
|
|
942
|
+
commandId: 'sort-1',
|
|
943
|
+
identity,
|
|
944
|
+
expectedRevision: human?.revision ?? 0,
|
|
945
|
+
controlId: 'toggle-sorting',
|
|
946
|
+
payload: { columnId: 'status' },
|
|
947
|
+
});
|
|
948
|
+
await vi.waitFor(() => expect(link.messages).toHaveLength(1));
|
|
949
|
+
const request = link.messages.at(-1);
|
|
950
|
+
expect(request?.type).toBe('data-surface.command');
|
|
951
|
+
const replayPromise = browser.send({
|
|
952
|
+
version: 1,
|
|
953
|
+
commandId: 'sort-1',
|
|
954
|
+
identity,
|
|
955
|
+
expectedRevision: human?.revision ?? 0,
|
|
956
|
+
controlId: 'toggle-sorting',
|
|
957
|
+
payload: { columnId: 'status' },
|
|
958
|
+
});
|
|
959
|
+
await expect(browser.send({
|
|
960
|
+
version: 1,
|
|
961
|
+
commandId: 'sort-1',
|
|
962
|
+
identity,
|
|
963
|
+
expectedRevision: human?.revision ?? 0,
|
|
964
|
+
controlId: 'toggle-sorting',
|
|
965
|
+
payload: { columnId: 'name' },
|
|
966
|
+
})).resolves.toMatchObject({ ok: false, reason: 'idempotency_conflict' });
|
|
967
|
+
const messagesBeforeSpoof = link.messages.length;
|
|
968
|
+
expect(await browser.send({
|
|
969
|
+
version: 1,
|
|
970
|
+
commandId: 'denied',
|
|
971
|
+
identity: { ...identity, subject: { type: 'tenant', id: 'tenant-b' } },
|
|
972
|
+
expectedRevision: human?.revision ?? 0,
|
|
973
|
+
controlId: 'set-page',
|
|
974
|
+
payload: { page: 2 },
|
|
975
|
+
})).toMatchObject({ ok: false, reason: 'denied' });
|
|
976
|
+
expect(link.messages).toHaveLength(messagesBeforeSpoof);
|
|
977
|
+
link.status('disconnected');
|
|
978
|
+
expect(await commandPromise).toMatchObject({
|
|
979
|
+
ok: false,
|
|
980
|
+
reason: 'disconnected',
|
|
981
|
+
});
|
|
982
|
+
await expect(replayPromise).resolves.toMatchObject({
|
|
983
|
+
ok: false,
|
|
984
|
+
reason: 'disconnected',
|
|
985
|
+
});
|
|
986
|
+
browser.dispose();
|
|
987
|
+
const timeoutLink = bridgeTransport();
|
|
988
|
+
const timeoutBridge = createDataSurfaceCommandBridge({
|
|
989
|
+
transport: timeoutLink,
|
|
990
|
+
sessionId: 'session-1',
|
|
991
|
+
source: 'server-1',
|
|
992
|
+
peerSource: 'browser-1',
|
|
993
|
+
authorize: () => true,
|
|
994
|
+
now: () => 1_000,
|
|
995
|
+
ttlMs: 20,
|
|
996
|
+
timeoutMs: 5,
|
|
997
|
+
});
|
|
998
|
+
await expect(timeoutBridge.send({
|
|
999
|
+
version: 1,
|
|
1000
|
+
commandId: 'timeout-command',
|
|
1001
|
+
identity,
|
|
1002
|
+
expectedRevision: replayed.revision ?? 0,
|
|
1003
|
+
controlId: 'set-page',
|
|
1004
|
+
payload: { page: 2 },
|
|
1005
|
+
})).resolves.toMatchObject({ ok: false, reason: 'timeout' });
|
|
1006
|
+
timeoutBridge.dispose();
|
|
1007
|
+
const malformed = createDataSurfaceCommandBridge({
|
|
1008
|
+
transport: bridgeTransport(),
|
|
1009
|
+
sessionId: 'session-1',
|
|
1010
|
+
source: 'server-1',
|
|
1011
|
+
peerSource: 'browser-1',
|
|
1012
|
+
authorize: () => true,
|
|
1013
|
+
ttlMs: 1_000,
|
|
1014
|
+
timeoutMs: 10,
|
|
1015
|
+
});
|
|
1016
|
+
expect(await malformed.send({ version: 1 })).toMatchObject({
|
|
1017
|
+
ok: false,
|
|
1018
|
+
reason: 'invalid_request',
|
|
1019
|
+
});
|
|
1020
|
+
malformed.dispose();
|
|
1021
|
+
});
|
|
1022
|
+
it('requires a fresh opaque confirmation and rechecks tenant/eligibility on apply', async () => {
|
|
1023
|
+
let revision = 1;
|
|
1024
|
+
let authorizationAllowed = true;
|
|
1025
|
+
let eligibilityAllowed = true;
|
|
1026
|
+
let authoritativeSelection = {
|
|
1027
|
+
queryFingerprint: 'records-query-v1',
|
|
1028
|
+
rowIds: [firstId],
|
|
1029
|
+
};
|
|
1030
|
+
const applied = [];
|
|
1031
|
+
const state = new InMemoryDataSurfaceActionStateStore();
|
|
1032
|
+
const context = {
|
|
1033
|
+
principal: {
|
|
1034
|
+
db,
|
|
1035
|
+
principal: {
|
|
1036
|
+
runAsUserId: 'surface-user',
|
|
1037
|
+
tenantId: 'tenant-a',
|
|
1038
|
+
allowedTools: ['records.archive'],
|
|
1039
|
+
},
|
|
1040
|
+
onBehalfOfUserId: 'human-user',
|
|
1041
|
+
permissions: ['records.read', 'records.update'],
|
|
1042
|
+
postgresRls: false,
|
|
1043
|
+
audit: () => undefined,
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
const adapter = createDataSurfaceActionAdapter({
|
|
1047
|
+
state,
|
|
1048
|
+
resolveSurface: async (run) => ({
|
|
1049
|
+
descriptor,
|
|
1050
|
+
revision,
|
|
1051
|
+
actions: {
|
|
1052
|
+
archive: {
|
|
1053
|
+
descriptor: descriptor.actions[0],
|
|
1054
|
+
inputSchema: null,
|
|
1055
|
+
validatePayload: () => ({ valid: true }),
|
|
1056
|
+
confirmation: 'required',
|
|
1057
|
+
execution: 'foreground',
|
|
1058
|
+
tool: 'records.archive',
|
|
1059
|
+
operation: {
|
|
1060
|
+
id: 'records:update',
|
|
1061
|
+
collection: 'records',
|
|
1062
|
+
action: 'update',
|
|
1063
|
+
},
|
|
1064
|
+
authorize: async (invocation) => authorizationAllowed &&
|
|
1065
|
+
invocation.run.context.tenantId === 'tenant-a',
|
|
1066
|
+
eligible: async (_invocation, rowId) => rowId === firstId && eligibilityAllowed
|
|
1067
|
+
? { eligible: true }
|
|
1068
|
+
: { eligible: false, reason: 'no longer eligible' },
|
|
1069
|
+
apply: async (_invocation, rowId) => {
|
|
1070
|
+
const row = await records.get(String(rowId));
|
|
1071
|
+
if (!row)
|
|
1072
|
+
throw new Error('missing');
|
|
1073
|
+
row.status = 'archived';
|
|
1074
|
+
await row.save();
|
|
1075
|
+
applied.push(String(rowId));
|
|
1076
|
+
return undefined;
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
}),
|
|
1081
|
+
resolveSelection: async (invocation, _selection) => ({
|
|
1082
|
+
revision,
|
|
1083
|
+
queryFingerprint: authoritativeSelection.queryFingerprint,
|
|
1084
|
+
rowIds: authoritativeSelection.rowIds,
|
|
1085
|
+
}),
|
|
1086
|
+
});
|
|
1087
|
+
const base = {
|
|
1088
|
+
version: 1,
|
|
1089
|
+
requestId: 'archive-preview',
|
|
1090
|
+
identity,
|
|
1091
|
+
actionId: 'archive',
|
|
1092
|
+
phase: 'preview',
|
|
1093
|
+
selection: { scope: 'explicit-ids', rowIds: [firstId] },
|
|
1094
|
+
expectedRevision: 1,
|
|
1095
|
+
};
|
|
1096
|
+
expect(await adapter.apply({
|
|
1097
|
+
...base,
|
|
1098
|
+
phase: 'apply',
|
|
1099
|
+
requestId: 'without-preview',
|
|
1100
|
+
idempotencyKey: 'without-preview',
|
|
1101
|
+
}, context)).toMatchObject({ ok: false, reason: 'confirmation_required' });
|
|
1102
|
+
const preview = await adapter.preview(base, context);
|
|
1103
|
+
expect(preview).toMatchObject({
|
|
1104
|
+
ok: true,
|
|
1105
|
+
confirmationToken: expect.any(String),
|
|
1106
|
+
});
|
|
1107
|
+
const token = preview.confirmationToken;
|
|
1108
|
+
expect(token.length).toBeGreaterThan(20);
|
|
1109
|
+
expect(token).not.toContain('tenant-a');
|
|
1110
|
+
expect(token).not.toContain('archive');
|
|
1111
|
+
// The selected rows and their authoritative query can change without a
|
|
1112
|
+
// surface revision changing. Applying the old token must re-resolve that
|
|
1113
|
+
// selection and fail closed before the action callback is reached.
|
|
1114
|
+
const selectionDriftPreview = await adapter.preview({ ...base, requestId: 'selection-drift-preview' }, context);
|
|
1115
|
+
authoritativeSelection = {
|
|
1116
|
+
queryFingerprint: 'records-query-v2',
|
|
1117
|
+
rowIds: [secondId],
|
|
1118
|
+
};
|
|
1119
|
+
const selectionDrift = await adapter.apply({
|
|
1120
|
+
...base,
|
|
1121
|
+
phase: 'apply',
|
|
1122
|
+
requestId: 'selection-drift',
|
|
1123
|
+
idempotencyKey: 'selection-drift',
|
|
1124
|
+
confirmationToken: selectionDriftPreview.confirmationToken,
|
|
1125
|
+
}, context);
|
|
1126
|
+
expect(selectionDrift).toMatchObject({
|
|
1127
|
+
ok: false,
|
|
1128
|
+
reason: 'stale_preview',
|
|
1129
|
+
});
|
|
1130
|
+
expect(applied).toEqual([]);
|
|
1131
|
+
authoritativeSelection = {
|
|
1132
|
+
queryFingerprint: 'records-query-v1',
|
|
1133
|
+
rowIds: [firstId],
|
|
1134
|
+
};
|
|
1135
|
+
revision = 2;
|
|
1136
|
+
const stale = await adapter.apply({
|
|
1137
|
+
...base,
|
|
1138
|
+
phase: 'apply',
|
|
1139
|
+
requestId: 'stale',
|
|
1140
|
+
idempotencyKey: 'stale',
|
|
1141
|
+
confirmationToken: token,
|
|
1142
|
+
}, context);
|
|
1143
|
+
expect(stale).toMatchObject({ ok: false, reason: 'stale_preview' });
|
|
1144
|
+
revision = 1;
|
|
1145
|
+
const freshPreview = await adapter.preview({ ...base, requestId: 'fresh-preview' }, context);
|
|
1146
|
+
expect(freshPreview).toMatchObject({
|
|
1147
|
+
ok: true,
|
|
1148
|
+
confirmationToken: expect.any(String),
|
|
1149
|
+
});
|
|
1150
|
+
expect(freshPreview.confirmationToken).not.toBe(token);
|
|
1151
|
+
authorizationAllowed = false;
|
|
1152
|
+
const authorizationFlip = await adapter.apply({
|
|
1153
|
+
...base,
|
|
1154
|
+
phase: 'apply',
|
|
1155
|
+
requestId: 'authorization-flip',
|
|
1156
|
+
idempotencyKey: 'authorization-flip',
|
|
1157
|
+
confirmationToken: freshPreview.confirmationToken,
|
|
1158
|
+
}, context);
|
|
1159
|
+
expect(authorizationFlip).toMatchObject({ ok: false, reason: 'denied' });
|
|
1160
|
+
expect(applied).toEqual([]);
|
|
1161
|
+
authorizationAllowed = true;
|
|
1162
|
+
const eligibilityPreview = await adapter.preview({ ...base, requestId: 'eligibility-flip-preview' }, context);
|
|
1163
|
+
eligibilityAllowed = false;
|
|
1164
|
+
const eligibilityFlip = await adapter.apply({
|
|
1165
|
+
...base,
|
|
1166
|
+
phase: 'apply',
|
|
1167
|
+
requestId: 'eligibility-flip',
|
|
1168
|
+
idempotencyKey: 'eligibility-flip',
|
|
1169
|
+
confirmationToken: eligibilityPreview.confirmationToken,
|
|
1170
|
+
}, context);
|
|
1171
|
+
expect(eligibilityFlip).toMatchObject({
|
|
1172
|
+
ok: true,
|
|
1173
|
+
details: { accepted: 0, skipped: 1 },
|
|
1174
|
+
});
|
|
1175
|
+
expect(applied).toEqual([]);
|
|
1176
|
+
eligibilityAllowed = true;
|
|
1177
|
+
const crossTenantPreview = await adapter.preview({ ...base, requestId: 'cross-tenant-preview' }, context);
|
|
1178
|
+
const otherTenant = {
|
|
1179
|
+
...context,
|
|
1180
|
+
principal: {
|
|
1181
|
+
...context.principal,
|
|
1182
|
+
principal: { ...context.principal.principal, tenantId: 'tenant-b' },
|
|
1183
|
+
},
|
|
1184
|
+
};
|
|
1185
|
+
await expect(adapter.apply({
|
|
1186
|
+
...base,
|
|
1187
|
+
phase: 'apply',
|
|
1188
|
+
requestId: 'cross-tenant-apply',
|
|
1189
|
+
idempotencyKey: 'cross-tenant-apply',
|
|
1190
|
+
confirmationToken: crossTenantPreview.confirmationToken,
|
|
1191
|
+
}, otherTenant)).resolves.toMatchObject({ ok: false, reason: 'confirmation_mismatch' });
|
|
1192
|
+
const otherPrincipal = {
|
|
1193
|
+
...context,
|
|
1194
|
+
principal: {
|
|
1195
|
+
...context.principal,
|
|
1196
|
+
principal: {
|
|
1197
|
+
...context.principal.principal,
|
|
1198
|
+
runAsUserId: 'different-user',
|
|
1199
|
+
},
|
|
1200
|
+
},
|
|
1201
|
+
};
|
|
1202
|
+
await expect(adapter.apply({
|
|
1203
|
+
...base,
|
|
1204
|
+
phase: 'apply',
|
|
1205
|
+
requestId: 'cross-principal-apply',
|
|
1206
|
+
idempotencyKey: 'cross-principal-apply',
|
|
1207
|
+
confirmationToken: crossTenantPreview.confirmationToken,
|
|
1208
|
+
}, otherPrincipal)).resolves.toMatchObject({ ok: false, reason: 'confirmation_mismatch' });
|
|
1209
|
+
const finalPreview = await adapter.preview({ ...base, requestId: 'final-preview' }, context);
|
|
1210
|
+
const applyRequest = {
|
|
1211
|
+
...base,
|
|
1212
|
+
phase: 'apply',
|
|
1213
|
+
requestId: 'archive-apply',
|
|
1214
|
+
idempotencyKey: 'archive-apply',
|
|
1215
|
+
confirmationToken: finalPreview.confirmationToken,
|
|
1216
|
+
};
|
|
1217
|
+
const appliedResult = await adapter.apply(applyRequest, context);
|
|
1218
|
+
expect(appliedResult).toMatchObject({ ok: true, details: { accepted: 1 } });
|
|
1219
|
+
expect(applied).toEqual([firstId]);
|
|
1220
|
+
expect(await adapter.apply(applyRequest, context)).toEqual(appliedResult);
|
|
1221
|
+
expect(applied).toHaveLength(1);
|
|
1222
|
+
await expect(adapter.apply({
|
|
1223
|
+
...applyRequest,
|
|
1224
|
+
requestId: 'confirmation-replay',
|
|
1225
|
+
idempotencyKey: 'confirmation-replay',
|
|
1226
|
+
}, context)).resolves.toMatchObject({ ok: false, reason: 'confirmation_replayed' });
|
|
1227
|
+
expect(await adapter.apply({
|
|
1228
|
+
...applyRequest,
|
|
1229
|
+
requestId: 'forged',
|
|
1230
|
+
idempotencyKey: 'forged',
|
|
1231
|
+
confirmationToken: 'forged-token',
|
|
1232
|
+
}, context)).toMatchObject({ ok: false, reason: 'invalid_or_expired_confirmation' });
|
|
1233
|
+
const otherPreview = await adapter.preview({ ...base, requestId: 'other-tenant' }, otherTenant);
|
|
1234
|
+
expect(otherPreview).toMatchObject({ ok: false, reason: 'denied' });
|
|
1235
|
+
});
|
|
1236
|
+
it('keeps ContentList and report adapters on the same stable identity/version contract', async () => {
|
|
1237
|
+
const contentDescriptor = buildContentListSurfaceDescriptor({
|
|
1238
|
+
surfaceId: 'content-list-conformance',
|
|
1239
|
+
});
|
|
1240
|
+
expect(contentDescriptor).toMatchObject({
|
|
1241
|
+
version: 1,
|
|
1242
|
+
schemaVersion: CONTENT_LIST_SCHEMA_VERSION,
|
|
1243
|
+
rowKey: 'id',
|
|
1244
|
+
identity: { kind: 'table' },
|
|
1245
|
+
});
|
|
1246
|
+
expect(contentDescriptor.columns.find((column) => column.id === 'description')).toBeUndefined();
|
|
1247
|
+
expect(contentDescriptor.actions.find((action) => action.id === 'delete')).toMatchObject({ requiresConfirmation: true });
|
|
1248
|
+
const report = await buildReportAdapterDescriptor(SurfaceConformanceReport, { tenantScope: 'current' });
|
|
1249
|
+
expect(report).toMatchObject({
|
|
1250
|
+
version: 1,
|
|
1251
|
+
identityField: 'id',
|
|
1252
|
+
resourceId: expect.stringContaining('#current'),
|
|
1253
|
+
queryExecution: { modes: expect.arrayContaining(['silent']) },
|
|
1254
|
+
});
|
|
1255
|
+
expect(report.columns.find((column) => column.fieldName === 'secret')).toBeUndefined();
|
|
1256
|
+
// Execute the report adapter against a real tenant-scoped
|
|
1257
|
+
// SmrtReportCollection. This catches adapters that build a safe
|
|
1258
|
+
// descriptor but forget to carry the tenant boundary into row execution.
|
|
1259
|
+
const reportCollection = {
|
|
1260
|
+
// The fixture's real tenant-scoped collection owns the SQL and tenancy
|
|
1261
|
+
// interceptor. This adapter seam only maps its hydrated objects to the
|
|
1262
|
+
// report adapter's JSON row contract; it deliberately ignores the
|
|
1263
|
+
// adapter's already-validated select hint because the fixture schema is
|
|
1264
|
+
// generated without persisted column metadata.
|
|
1265
|
+
list: async (_options) => (await records.list()).map((row) => ({
|
|
1266
|
+
id: String(row.id),
|
|
1267
|
+
name: row.name,
|
|
1268
|
+
})),
|
|
1269
|
+
count: async (_options) => (await records.list()).length,
|
|
1270
|
+
};
|
|
1271
|
+
const reportRows = await withTenant({ tenantId: 'tenant-a' }, () => queryReportMaterializedRows(SurfaceConformanceReport, {
|
|
1272
|
+
version: 1,
|
|
1273
|
+
requestId: 'report-visible',
|
|
1274
|
+
mode: 'rows',
|
|
1275
|
+
projection: ['id', 'name'],
|
|
1276
|
+
page: { kind: 'offset', offset: 0, limit: 10 },
|
|
1277
|
+
}, {
|
|
1278
|
+
db,
|
|
1279
|
+
collection: reportCollection,
|
|
1280
|
+
adapter: { tenantScope: 'current' },
|
|
1281
|
+
execution: 'visible',
|
|
1282
|
+
}));
|
|
1283
|
+
expect(reportRows).toMatchObject({
|
|
1284
|
+
execution: 'visible',
|
|
1285
|
+
rows: expect.arrayContaining([
|
|
1286
|
+
{ id: firstId, name: 'Ada' },
|
|
1287
|
+
{ id: secondId, name: 'Grace' },
|
|
1288
|
+
]),
|
|
1289
|
+
total: { kind: 'exact', value: 2 },
|
|
1290
|
+
});
|
|
1291
|
+
expect(JSON.stringify(reportRows)).not.toContain(hiddenId);
|
|
1292
|
+
// Mount the shipped ContentList itself (compact mode owns the DataTable
|
|
1293
|
+
// surface) and drive its generated descriptor through the same registry
|
|
1294
|
+
// command path used by the standalone human table above.
|
|
1295
|
+
const contentRegistry = createDataSurfaceRegistry();
|
|
1296
|
+
const content = buildContentListSurfaceDescriptor({
|
|
1297
|
+
surfaceId: 'content-list-mounted',
|
|
1298
|
+
});
|
|
1299
|
+
const contentRows = [
|
|
1300
|
+
{
|
|
1301
|
+
id: 'content-a',
|
|
1302
|
+
title: 'Ada article',
|
|
1303
|
+
type: 'article',
|
|
1304
|
+
status: 'published',
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
id: 'content-b',
|
|
1308
|
+
title: 'Grace article',
|
|
1309
|
+
type: 'article',
|
|
1310
|
+
status: 'draft',
|
|
1311
|
+
},
|
|
1312
|
+
];
|
|
1313
|
+
const { container: contentContainer } = render(ContentList, {
|
|
1314
|
+
props: {
|
|
1315
|
+
defaultViewMode: 'compact',
|
|
1316
|
+
contents: contentRows,
|
|
1317
|
+
onEdit: vi.fn(),
|
|
1318
|
+
onDelete: vi.fn(),
|
|
1319
|
+
onAdd: vi.fn(),
|
|
1320
|
+
dataSurface: { registry: contentRegistry, descriptor: content },
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
await tick();
|
|
1324
|
+
await vi.waitFor(() => expect(contentRegistry.inspect(content.identity)).toBeDefined());
|
|
1325
|
+
const contentHuman = contentRegistry.inspect(content.identity);
|
|
1326
|
+
const contentBrowser = await contentRegistry.execute({
|
|
1327
|
+
version: 1,
|
|
1328
|
+
commandId: 'content-search',
|
|
1329
|
+
identity: content.identity,
|
|
1330
|
+
expectedRevision: contentHuman?.revision ?? 0,
|
|
1331
|
+
controlId: 'set-search',
|
|
1332
|
+
payload: { search: 'Ada' },
|
|
1333
|
+
});
|
|
1334
|
+
expect(contentBrowser).toMatchObject({ ok: true });
|
|
1335
|
+
expect(contentRegistry.inspect(content.identity)?.state).toMatchObject({
|
|
1336
|
+
table: { state: { search: 'Ada' } },
|
|
1337
|
+
});
|
|
1338
|
+
await expectNoA11yViolations(contentContainer);
|
|
1339
|
+
// The report presentation consumes the adapter's production table columns
|
|
1340
|
+
// while retaining the same stable row identity and mounted command state.
|
|
1341
|
+
const reportIdentity = {
|
|
1342
|
+
surfaceId: report.resourceId,
|
|
1343
|
+
kind: 'report',
|
|
1344
|
+
subject: { type: 'tenant', id: 'tenant-a' },
|
|
1345
|
+
};
|
|
1346
|
+
const reportSurface = {
|
|
1347
|
+
version: 1,
|
|
1348
|
+
identity: reportIdentity,
|
|
1349
|
+
schemaVersion: report.schema.version,
|
|
1350
|
+
label: report.reportClassName,
|
|
1351
|
+
rowKey: report.identityField,
|
|
1352
|
+
columns: report.dataTable.columns.map((column) => ({
|
|
1353
|
+
id: column.id,
|
|
1354
|
+
label: column.label,
|
|
1355
|
+
capabilities: [
|
|
1356
|
+
'read',
|
|
1357
|
+
...(column.sortable ? ['sort'] : []),
|
|
1358
|
+
...(column.filterable ? ['filter'] : []),
|
|
1359
|
+
],
|
|
1360
|
+
})),
|
|
1361
|
+
query: {
|
|
1362
|
+
modes: ['rows', 'count'],
|
|
1363
|
+
projectableColumnIds: report.schema.fields
|
|
1364
|
+
.filter((field) => field.projectable)
|
|
1365
|
+
.map((field) => field.id),
|
|
1366
|
+
sortableColumnIds: report.schema.fields
|
|
1367
|
+
.filter((field) => field.sortable)
|
|
1368
|
+
.map((field) => field.id),
|
|
1369
|
+
},
|
|
1370
|
+
controls: [],
|
|
1371
|
+
actions: [],
|
|
1372
|
+
limits: { maxQueryRows: 50, maxQueryBytes: 20_000, maxSelectionSize: 10 },
|
|
1373
|
+
};
|
|
1374
|
+
const reportRegistry = createDataSurfaceRegistry();
|
|
1375
|
+
const { container: reportContainer } = render(DataTable, {
|
|
1376
|
+
props: {
|
|
1377
|
+
data: (reportRows.rows ?? []),
|
|
1378
|
+
columns: report.dataTable.columns,
|
|
1379
|
+
rowKey: report.dataTable.rowKey,
|
|
1380
|
+
controller: createDataTableController({
|
|
1381
|
+
columnIds: report.dataTable.columns.map((column) => column.id),
|
|
1382
|
+
}),
|
|
1383
|
+
caption: 'Surface Conformance Report',
|
|
1384
|
+
dataSurface: { registry: reportRegistry, descriptor: reportSurface },
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
await tick();
|
|
1388
|
+
await vi.waitFor(() => expect(reportRegistry.inspect(reportIdentity)).toBeDefined());
|
|
1389
|
+
expect(reportRegistry.inspect(reportIdentity)?.descriptor.identity).toEqual(reportIdentity);
|
|
1390
|
+
await expectNoA11yViolations(reportContainer);
|
|
1391
|
+
});
|
|
1392
|
+
});
|