@substrat-run/contract-tests 0.52.0 → 0.54.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.
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
3
- import { connectionId, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
3
+ import { connectionId, dataSubjectId, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
4
4
  import { runPlatformSweep, ulid } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
6
6
  /**
@@ -536,6 +536,136 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
536
536
  await expect(host.admin.exportScope(staff, t2, s1)).rejects.toThrow();
537
537
  });
538
538
  });
539
+ // -- subject erasure (#37, master-plan §5.3) ------------------------------
540
+ //
541
+ // `piiClass` has been enforced at the type level since the contracts package existed —
542
+ // an event carrying PII cannot be declared without a `subjectId`, because
543
+ // "crypto-shredding must be able to key the erasure". These are that erasure, and
544
+ // every adapter owes all of it.
545
+ //
546
+ // The mechanism splits the way the STORES split. Tier 1 is mutable, so erasing there
547
+ // is redaction: the payload goes, the envelope stays. A platform-retained COPY is not
548
+ // mutable — that is what a backup IS — so erasing there is cryptographic: the copy was
549
+ // sealed per-subject on the way out, and destroying that key reaches backwards into
550
+ // every copy already taken. Both halves are asserted here; what a dump looks like
551
+ // after each is `sealDump`/`openDump`'s job (control-plane-api).
552
+ describe('subject erasure (#37)', () => {
553
+ /** Every outbox row for one subject, read through the module's own projection. */
554
+ const spineFor = async (subject) => {
555
+ const stub = await host.getScope(alice, t1, s1);
556
+ const rows = (await stub.invoke('test/read-outbox', undefined));
557
+ return rows.filter((r) => r.subject_id === subject);
558
+ };
559
+ it('redacts the payload and KEEPS the envelope — and only for the named subject', async () => {
560
+ const alicia = dataSubjectId.parse(ulid());
561
+ const bruno = dataSubjectId.parse(ulid());
562
+ const stub = await host.getScope(alice, t1, s1);
563
+ await stub.invoke('test/emit-event', { subject: alicia, secret: 'alicia-was-here' });
564
+ await stub.invoke('test/emit-event', { subject: bruno, secret: 'bruno-was-here' });
565
+ const before = await spineFor(alicia);
566
+ expect(before).toHaveLength(1);
567
+ expect(before[0].payload).toContain('alicia-was-here');
568
+ const receipt = await host.admin.shredSubject(staff, t1, s1, alicia);
569
+ expect(receipt.subjectId).toBe(alicia);
570
+ expect(receipt.eventsRedacted).toBe(1);
571
+ expect(receipt.tombstoned).toBe(true);
572
+ // The payload is gone. The ENVELOPE is not: master-plan §5.3 keeps the
573
+ // pseudonymous key and the transaction fact, so a timeline still shows that
574
+ // something happened, to what, and when — it no longer shows what was said.
575
+ const after = await spineFor(alicia);
576
+ expect(after).toHaveLength(1);
577
+ expect(after[0].payload).toBeNull();
578
+ expect(after[0].id).toBe(before[0].id);
579
+ expect(after[0].pii_class).toBe('pseudonymous');
580
+ expect(after[0].subject_id).toBe(alicia);
581
+ // The other subject is untouched — an erasure that over-reaches is its own bug.
582
+ const others = await spineFor(bruno);
583
+ expect(others[0].payload).toContain('bruno-was-here');
584
+ });
585
+ it('is idempotent — a re-run erases nothing and still reports the tombstone', async () => {
586
+ const subject = dataSubjectId.parse(ulid());
587
+ const stub = await host.getScope(alice, t1, s1);
588
+ await stub.invoke('test/emit-event', { subject, secret: 'once' });
589
+ const first = await host.admin.shredSubject(staff, t1, s1, subject);
590
+ expect(first.eventsRedacted).toBe(1);
591
+ // A retry after a crash must converge rather than double-count or throw: the
592
+ // payloads are already null and the key is already destroyed.
593
+ const second = await host.admin.shredSubject(staff, t1, s1, subject);
594
+ expect(second.eventsRedacted).toBe(0);
595
+ expect(second.keyDestroyed).toBe(false);
596
+ expect(second.tombstoned).toBe(true);
597
+ });
598
+ it('seals and opens a payload under the subject who owns it', async () => {
599
+ const subject = dataSubjectId.parse(ulid());
600
+ const sealed = await host.admin.sealSubjectPayloads(staff, t1, s1, [
601
+ { subjectId: subject, plaintext: '{"note":"only for them"}' },
602
+ ]);
603
+ expect(sealed[0]).not.toBeNull();
604
+ // Sealed means sealed: the plaintext is not sitting in the envelope.
605
+ expect(sealed[0].ciphertext).not.toContain('only for them');
606
+ const opened = await host.admin.openSubjectPayloads(staff, t1, s1, [
607
+ { subjectId: subject, sealed: sealed[0] },
608
+ ]);
609
+ expect(opened[0]).toBe('{"note":"only for them"}');
610
+ });
611
+ it('after a shred: what was sealed no longer opens, and nothing may be re-sealed', async () => {
612
+ const subject = dataSubjectId.parse(ulid());
613
+ const [sealed] = await host.admin.sealSubjectPayloads(staff, t1, s1, [
614
+ { subjectId: subject, plaintext: 'in the backup' },
615
+ ]);
616
+ await host.admin.shredSubject(staff, t1, s1, subject);
617
+ // THE property. The ciphertext is untouched — it is still sitting in whatever copy
618
+ // it was written to — and it is now permanently unreadable. This is the only
619
+ // mechanism that reaches into an immutable store, which is why it exists.
620
+ const opened = await host.admin.openSubjectPayloads(staff, t1, s1, [
621
+ { subjectId: subject, sealed: sealed },
622
+ ]);
623
+ expect(opened[0]).toBeNull();
624
+ // And the tombstone holds: a LATER export must not mint this subject a fresh
625
+ // working key. Without this, the next backup would quietly undo the erasure.
626
+ const resealed = await host.admin.sealSubjectPayloads(staff, t1, s1, [
627
+ { subjectId: subject, plaintext: 'in the backup' },
628
+ ]);
629
+ expect(resealed[0]).toBeNull();
630
+ });
631
+ it('leaves a different subject in the same scope fully readable', async () => {
632
+ const shredded = dataSubjectId.parse(ulid());
633
+ const spared = dataSubjectId.parse(ulid());
634
+ const [a, b] = await host.admin.sealSubjectPayloads(staff, t1, s1, [
635
+ { subjectId: shredded, plaintext: 'theirs' },
636
+ { subjectId: spared, plaintext: 'not theirs' },
637
+ ]);
638
+ await host.admin.shredSubject(staff, t1, s1, shredded);
639
+ const opened = await host.admin.openSubjectPayloads(staff, t1, s1, [
640
+ { subjectId: shredded, sealed: a },
641
+ { subjectId: spared, sealed: b },
642
+ ]);
643
+ // Per-subject keys, not a per-scope one: erasing a person must not cost the
644
+ // backup its ability to restore everyone else.
645
+ expect(opened[0]).toBeNull();
646
+ expect(opened[1]).toBe('not theirs');
647
+ });
648
+ it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
649
+ const subject = dataSubjectId.parse(ulid());
650
+ // Naming another tenant's scope must not reach its keys — or erase in it.
651
+ await expect(host.admin.shredSubject(staff, t2, s1, subject)).rejects.toThrow();
652
+ await expect(host.admin.sealSubjectPayloads(staff, t2, s1, [{ subjectId: subject, plaintext: 'x' }])).rejects.toThrow();
653
+ });
654
+ it('records the erasure in BOTH logs — mutation and evidence-destruction', async () => {
655
+ const subject = dataSubjectId.parse(ulid());
656
+ await host.admin.shredSubject(staff, t1, s1, subject);
657
+ // The admin log because it is a mutation, carrying the receipt as `after`: an
658
+ // erasure that leaves no proof it ran cannot answer a DSAR.
659
+ const audit = await host.admin.auditLog(staff, { tenantId: t1 });
660
+ const entry = audit.filter((e) => e.action === 'shredSubject').at(-1);
661
+ expect(entry).toBeDefined();
662
+ expect(JSON.stringify(entry.after)).toContain(subject);
663
+ // The access log because it DESTROYS evidence — "who asked for this to
664
+ // disappear" is itself part of the record.
665
+ const access = await host.admin.accessLog(staff, { tenantId: t1, method: 'shredSubject' });
666
+ expect(access.length).toBeGreaterThan(0);
667
+ });
668
+ });
539
669
  // -- scope import: the fork round-trip (preview-and-snapshots.md §3) -------
540
670
  //
541
671
  // The write side of exportScope. `importScope` provisions a NEW scope and loads
@@ -1028,6 +1158,60 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1028
1158
  });
1029
1159
  });
1030
1160
  // -- the integrations hub: connections (#101) -----------------------------
1161
+ // -- operational failures (#559): the durable record of what the platform could NOT do --
1162
+ describe('operational failures (#559)', () => {
1163
+ it('records a failure and finds it again — by reference, by vertical, newest first', async () => {
1164
+ await host.admin.recordOpsFailure({
1165
+ actor: staff,
1166
+ operation: 'deploy.upload',
1167
+ stage: 'wfp-upload',
1168
+ tenantId: t1,
1169
+ vertical: 'acme/crm',
1170
+ status: 502,
1171
+ message: 'WfP upload failed (500): internal error; reference = testref123abc',
1172
+ reference: 'testref123abc',
1173
+ });
1174
+ await host.admin.recordOpsFailure({
1175
+ actor: staff,
1176
+ operation: 'POST /verticals/:slug/previews',
1177
+ vertical: 'acme/crm',
1178
+ status: 502,
1179
+ message: 'internal error; reference = otherref456',
1180
+ reference: 'otherref456',
1181
+ });
1182
+ // Newest first by default — an operator asks "what broke lately", and the
1183
+ // adapter's ULIDs are monotonic, so creation order IS id order.
1184
+ const recent = await host.admin.listOpsFailures(staff, { vertical: 'acme/crm' });
1185
+ expect(recent.length).toBe(2);
1186
+ expect(recent[0].operation).toBe('POST /verticals/:slug/previews');
1187
+ // The lookup a CI log's `reference = <id>` line lands on.
1188
+ const byRef = await host.admin.listOpsFailures(staff, { reference: 'testref123abc' });
1189
+ expect(byRef.length).toBe(1);
1190
+ expect(byRef[0].stage).toBe('wfp-upload');
1191
+ expect(byRef[0].tenantId).toBe(t1);
1192
+ expect(byRef[0].status).toBe(502);
1193
+ // Cursor pages exactly like the audit log: the entry id IS the cursor.
1194
+ const page1 = await host.admin.listOpsFailures(staff, { vertical: 'acme/crm', limit: 1 });
1195
+ const page2 = await host.admin.listOpsFailures(staff, {
1196
+ vertical: 'acme/crm',
1197
+ limit: 1,
1198
+ cursor: page1[0].id,
1199
+ });
1200
+ expect(page1.length).toBe(1);
1201
+ expect(page2.length).toBe(1);
1202
+ expect(page2[0].id).not.toBe(page1[0].id);
1203
+ });
1204
+ it('bounds the recorded message — a runaway upstream body never becomes a runaway row', async () => {
1205
+ await host.admin.recordOpsFailure({
1206
+ actor: staff,
1207
+ operation: 'contract.bound-check',
1208
+ message: 'x'.repeat(10_000),
1209
+ });
1210
+ const rows = await host.admin.listOpsFailures(staff, { operation: 'contract.bound-check' });
1211
+ expect(rows.length).toBe(1);
1212
+ expect(rows[0].message.length).toBeLessThanOrEqual(2000);
1213
+ });
1214
+ });
1031
1215
  //
1032
1216
  // The store exists so a vertical's connector can reach a tenant's provider
1033
1217
  // without any module ever holding a credential. So the properties that
@@ -1820,6 +2004,21 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1820
2004
  await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', vid);
1821
2005
  expect((await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')?.versionId).toBe(vid);
1822
2006
  });
2007
+ it('getVersion reads ONE version by id, and fails closed across a lineage', async () => {
2008
+ // The read that replaced "list every version this vertical ever pushed, then
2009
+ // .find() one". Correctness first: same row, same shape as the list gives.
2010
+ const vid = await publishPrivate('egeryds/crm', '0.9.0');
2011
+ const one = await host.admin.getVersion(staff, vid);
2012
+ expect(one?.id).toBe(vid);
2013
+ expect(one).toEqual((await host.admin.listVersions(staff, 'egeryds/crm')).find((v) => v.id === vid));
2014
+ // Narrowed, it keeps what the old `.find()`-inside-one-slug's-list gave for free:
2015
+ // a version of ANOTHER vertical is absent, not returned across the boundary. Without
2016
+ // this, the slug in the URL stops constraining which version a route can hand back.
2017
+ expect((await host.admin.getVersion(staff, vid, 'egeryds/crm'))?.id).toBe(vid);
2018
+ expect(await host.admin.getVersion(staff, vid, 'callout')).toBeUndefined();
2019
+ // An id nobody published is absent, not a throw — callers branch on undefined.
2020
+ expect(await host.admin.getVersion(staff, ulid())).toBeUndefined();
2021
+ });
1823
2022
  it('a platform vertical still lands pending — auto-admission is scoped to private ownership', async () => {
1824
2023
  // 'callout' is platform-owned (ownerTenant null): the 9.9.9 push in the test
1825
2024
  // above landed pending. The distinction is the whole design: self-admission
@@ -1972,6 +2171,31 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1972
2171
  verticalSlug: 't-acme/crm',
1973
2172
  });
1974
2173
  });
2174
+ it('listHostnames narrows to ONE vertical, in the query', async () => {
2175
+ // The deploy path's surface-drift warning needs this vertical's bindings. It used
2176
+ // to read the WHOLE fleet's rows and filter in JS, which made a push's advisory
2177
+ // check depend on every other tenant's routing row being readable — one malformed
2178
+ // row anywhere took down deploys for everyone. Narrowing belongs in the query, so
2179
+ // the rows that answer the question are the only rows that can break it.
2180
+ const other = scopeId.parse(ulid());
2181
+ await host.provisionScope(staff, { tenantId: t1, scopeId: other, vertical: 'callout', jurisdiction: 'eu' });
2182
+ await host.admin.bindHostname(staff, {
2183
+ hostname: 'callout-narrow.global.example.com',
2184
+ tenantId: t1,
2185
+ scopeId: other,
2186
+ surface: 'app',
2187
+ region: null,
2188
+ canonical: true,
2189
+ });
2190
+ const mine = await host.admin.listHostnames(staff, { verticalSlug: 't-acme/crm' });
2191
+ expect(mine.length).toBeGreaterThan(0);
2192
+ expect(mine.every((h) => h.verticalSlug === 't-acme/crm')).toBe(true);
2193
+ expect(mine.map((h) => h.hostname)).not.toContain('callout-narrow.global.example.com');
2194
+ // Composes with the other filters rather than replacing them, and an unknown slug
2195
+ // is an empty list — never "no filter, here is everything".
2196
+ expect(await host.admin.listHostnames(staff, { verticalSlug: 'no-such-vertical' })).toEqual([]);
2197
+ expect(await host.admin.listHostnames(staff, { verticalSlug: 't-acme/crm', scopeId: other })).toEqual([]);
2198
+ });
1975
2199
  it('routes two surfaces of ONE scope to different hostnames', async () => {
1976
2200
  // The reason §5.5's one-hostname-per-scope was not enough: the shop fronts a
1977
2201
  // storefront and a back office from the same data, and RallyPoint a player