@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.
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +4 -1
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +42 -3
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +225 -1
- package/dist/scope-host-suite.js.map +1 -1
- package/package.json +3 -3
package/dist/scope-host-suite.js
CHANGED
|
@@ -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
|