@substrat-run/contract-tests 0.122.1 → 0.124.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.
@@ -1166,7 +1166,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1166
1166
  it('facets the outbox, and keeps an ERASED payload out of the null bucket (#1239)', async () => {
1167
1167
  const stub = await host.getScope(alice, t1, s1);
1168
1168
  const subject = ulid();
1169
- // Three events: two whose payload carries `secret`, one that never had it.
1169
+ // Three events: two whose payload carries `secret` — both classed pseudonymous,
1170
+ // since they name a subject — and one that never had it.
1170
1171
  await stub.invoke('test/emit-event', { subject, secret: 'kept' });
1171
1172
  await stub.invoke('test/emit-event', { subject: ulid(), secret: 'kept' });
1172
1173
  await stub.invoke('test/emit-event');
@@ -1180,7 +1181,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1180
1181
  type: 'test.happened',
1181
1182
  });
1182
1183
  expect(before.erased).toBe(0);
1183
- expect(before.buckets.find((b) => b.value === 'kept').count).toBe(2);
1184
+ // #1762: a classified event is withheld from a payload grouping, so the two
1185
+ // `kept` events are counted apart rather than bucketed.
1186
+ expect(before.buckets.find((b) => b.value === 'kept')).toBeUndefined();
1187
+ expect(before.withheldPersonal).toBeGreaterThanOrEqual(2);
1184
1188
  // The event that never carried the field is a genuine null bucket — an
1185
1189
  // absence in the data, which is a different fact from an erasure.
1186
1190
  expect(before.buckets.find((b) => b.value === null).count).toBeGreaterThanOrEqual(1);
@@ -1192,12 +1196,69 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1192
1196
  });
1193
1197
  // THE RULE: the erased event is counted apart, and did NOT join the null
1194
1198
  // bucket. Folding it in would show a reader a clean distribution with no
1195
- // hint that part of the history was redacted.
1199
+ // hint that part of the history was redacted. It moves from withheld to
1200
+ // erased — counted once, in the one place that says what happened to it.
1196
1201
  expect(after.erased).toBe(1);
1197
- expect(after.buckets.find((b) => b.value === 'kept').count).toBe(1);
1202
+ expect(after.withheldPersonal).toBe(before.withheldPersonal - 1);
1198
1203
  expect(after.buckets.find((b) => b.value === null)?.count ?? 0).toBe(before.buckets.find((b) => b.value === null).count);
1199
1204
  // And the denominator still counts it: the event happened, whatever it said.
1200
1205
  expect(after.total).toBe(before.total);
1206
+ expect(after.truncated).toBe(false);
1207
+ expect(after.buckets.reduce((n, b) => n + b.count, 0) + after.erased + after.withheldPersonal).toBe(after.total);
1208
+ });
1209
+ it('withholds personal-data events from a payload grouping, and only there (#1762)', async () => {
1210
+ const stub = await host.getScope(alice, t1, s1);
1211
+ const shredded = ulid();
1212
+ // One payload field under every class. Grouped by it, only the `none` events may
1213
+ // produce buckets: a bucket per address is a search for a person.
1214
+ await stub.invoke('test/emit-classified', { pii: 'none', email: 'ops@acme.test' });
1215
+ await stub.invoke('test/emit-classified', { pii: 'none', email: 'ops@acme.test' });
1216
+ await stub.invoke('test/emit-classified', { pii: 'pseudonymous', email: 'anna@acme.test' });
1217
+ await stub.invoke('test/emit-classified', { pii: 'pseudonymous', email: 'anna@acme.test' });
1218
+ await stub.invoke('test/emit-classified', { pii: 'direct', email: 'bo@acme.test' });
1219
+ await stub.invoke('test/emit-classified', { pii: 'direct', email: 'cy@acme.test', subject: shredded });
1220
+ await host.admin.shredSubject(staff, t1, s1, dataSubjectId.parse(shredded));
1221
+ const byEmail = await host.admin.facetEvents(staff, t1, s1, {
1222
+ groupBy: { kind: 'payload', field: 'email' },
1223
+ type: 'test.classified',
1224
+ });
1225
+ expect(byEmail.buckets.map((b) => [b.value, b.count])).toEqual([['ops@acme.test', 2]]);
1226
+ // Three classified events still carry their payload; the fourth was shredded and
1227
+ // is counted as erased, not twice.
1228
+ expect(byEmail.withheldPersonal).toBe(3);
1229
+ expect(byEmail.erased).toBe(1);
1230
+ expect(byEmail.total).toBe(6);
1231
+ expect(byEmail.buckets.reduce((n, b) => n + b.count, 0) + byEmail.erased + byEmail.withheldPersonal).toBe(byEmail.total);
1232
+ // The access row says what the read held back, on this co-located branch as on the
1233
+ // delegated one — `delegatedReadParams.facetEvents` is the one projection.
1234
+ const rows = (await host.admin.accessLog(staff, { method: 'facetEvents' }))
1235
+ .filter((e) => e.scopeId === s1)
1236
+ .map((e) => (typeof e.params === 'string' ? JSON.parse(e.params) : e.params))
1237
+ .filter((p) => p.groupBy?.field === 'email');
1238
+ expect(rows.at(-1)).toMatchObject({ withheldPersonal: 3 });
1239
+ expect(rows.at(-1)).not.toHaveProperty('withheldReason');
1240
+ // The row's params are cut at 500 chars, and `type` is unbounded: what was withheld
1241
+ // must survive a request long enough to be cut.
1242
+ const long = `test.${'x'.repeat(600)}`;
1243
+ await host.admin.facetEvents(staff, t1, s1, { groupBy: { kind: 'payload', field: 'email' }, type: long });
1244
+ const cut = (await host.admin.accessLog(staff, { method: 'facetEvents' }))
1245
+ .filter((e) => e.scopeId === s1)
1246
+ .map((e) => (typeof e.params === 'string' ? e.params : JSON.stringify(e.params)))
1247
+ .filter((text) => text.includes('x'.repeat(100)));
1248
+ expect(cut).toHaveLength(1);
1249
+ expect(cut[0].startsWith('{"withheldPersonal":0,')).toBe(true);
1250
+ // An envelope grouping is not payload content: every class is counted, and the
1251
+ // class itself stays available as a dimension.
1252
+ const byClass = await host.admin.facetEvents(staff, t1, s1, {
1253
+ groupBy: { kind: 'piiClass' },
1254
+ type: 'test.classified',
1255
+ });
1256
+ expect(Object.fromEntries(byClass.buckets.map((b) => [b.value, b.count]))).toEqual({
1257
+ none: 2,
1258
+ pseudonymous: 2,
1259
+ direct: 2,
1260
+ });
1261
+ expect([byClass.erased, byClass.withheldPersonal, byClass.total]).toEqual([0, 0, 6]);
1201
1262
  });
1202
1263
  it('carries WHEN each bucket last saw an event, not only how many (#1234)', async () => {
1203
1264
  const stub = await host.getScope(alice, t1, s1);
@@ -1244,6 +1305,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1244
1305
  // as erased would tell a reader that history was withheld from them when it
1245
1306
  // was simply never written.
1246
1307
  expect(facet.erased).toBe(0);
1308
+ expect(facet.withheldPersonal).toBe(0);
1247
1309
  expect(facet.buckets.find((b) => b.value === null).count).toBe(1);
1248
1310
  expect(facet.total).toBe(3);
1249
1311
  });
@@ -2321,6 +2383,43 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2321
2383
  const log = await host.admin.auditLog(staff, { limit: 500 });
2322
2384
  expect(log.some((e) => e.action === 'restoreDirectory')).toBe(true);
2323
2385
  });
2386
+ // A directory dump is untrusted input exactly as a scope dump is (#1143), and
2387
+ // both adapters refuse it with the same words. The DO path always judged it;
2388
+ // the node path ran the DDL through `exec`, appended statements included.
2389
+ it('refuses a dump whose DDL carries a second statement, and leaves the directory as it was (#1143)', async () => {
2390
+ const backup = await host.admin.exportDirectory(staff);
2391
+ const smuggling = {
2392
+ name: 'directory_probe',
2393
+ ddl: 'CREATE TABLE directory_probe (id TEXT); CREATE TABLE smuggled (id TEXT);',
2394
+ columns: ['id'],
2395
+ rows: [['1']],
2396
+ };
2397
+ await expect(host.admin.restoreDirectory(staff, { ...backup, tables: [...backup.tables, smuggling] })).rejects.toThrow(/more than one statement/);
2398
+ const after = (await host.admin.exportDirectory(staff)).tables.map((t) => t.name);
2399
+ expect(after).not.toContain('smuggled');
2400
+ expect(after).not.toContain('directory_probe');
2401
+ // Refused before anything was dropped: the platform still resolves and serves.
2402
+ expect(await host.admin.getTenant(staff, t1)).toBeDefined();
2403
+ const stub = await host.getScope(alice, t1, s1);
2404
+ await stub.invoke('test/write-marker', { v: 'after-refused-directory-restore' });
2405
+ });
2406
+ // The positive twin: the check does not refuse every extra table, and a `;` inside
2407
+ // a string literal is not read as a statement boundary.
2408
+ it('still restores an honest extra table whose DDL contains a quoted semicolon', async () => {
2409
+ const backup = await host.admin.exportDirectory(staff);
2410
+ const honest = {
2411
+ name: 'directory_probe',
2412
+ ddl: "CREATE TABLE directory_probe (id TEXT, note TEXT DEFAULT 'a;b')",
2413
+ columns: ['id', 'note'],
2414
+ rows: [['1', 'ok']],
2415
+ };
2416
+ await host.admin.restoreDirectory(staff, { ...backup, tables: [...backup.tables, honest] });
2417
+ const restored = (await host.admin.exportDirectory(staff)).tables.find((t) => t.name === 'directory_probe');
2418
+ expect(restored?.rows).toEqual([['1', 'ok']]);
2419
+ // Back to the copy, which does not carry the probe.
2420
+ await host.admin.restoreDirectory(staff, backup);
2421
+ expect((await host.admin.exportDirectory(staff)).tables.some((t) => t.name === 'directory_probe')).toBe(false);
2422
+ });
2324
2423
  });
2325
2424
  // -- restore (§8's write half): load a dump into an EXISTING scope in place ---
2326
2425
  describe('scope restore — backup/backout (§8)', () => {