@substrat-run/contract-tests 0.147.0 → 0.149.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/bind-module.d.ts +4 -0
- package/dist/bind-module.d.ts.map +1 -1
- package/dist/capability-expiry-suite.d.ts.map +1 -1
- package/dist/capability-expiry-suite.js +6 -4
- package/dist/capability-expiry-suite.js.map +1 -1
- package/dist/counter-runner.d.ts +22 -0
- package/dist/counter-runner.d.ts.map +1 -0
- package/dist/counter-runner.js +96 -0
- package/dist/counter-runner.js.map +1 -0
- package/dist/derived-module.d.ts +4 -0
- package/dist/derived-module.d.ts.map +1 -1
- package/dist/directory-restore-suite.d.ts.map +1 -1
- package/dist/directory-restore-suite.js +66 -2
- package/dist/directory-restore-suite.js.map +1 -1
- package/dist/directory-shapes.d.ts +6 -0
- package/dist/directory-shapes.d.ts.map +1 -1
- package/dist/directory-shapes.js +42 -0
- package/dist/directory-shapes.js.map +1 -1
- package/dist/entity-state-module.d.ts +8 -0
- package/dist/entity-state-module.d.ts.map +1 -1
- package/dist/entity-state-module.js +3 -0
- package/dist/entity-state-module.js.map +1 -1
- package/dist/entity-trash-module.d.ts +4 -0
- package/dist/entity-trash-module.d.ts.map +1 -1
- package/dist/entity-trash-suite.d.ts.map +1 -1
- package/dist/entity-trash-suite.js +20 -0
- package/dist/entity-trash-suite.js.map +1 -1
- package/dist/erasure-module.d.ts +4 -0
- package/dist/erasure-module.d.ts.map +1 -1
- package/dist/held-migrations.d.ts +27 -0
- package/dist/held-migrations.d.ts.map +1 -0
- package/dist/held-migrations.js +39 -0
- package/dist/held-migrations.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/migration-fence-suite.d.ts +3 -0
- package/dist/migration-fence-suite.d.ts.map +1 -0
- package/dist/migration-fence-suite.js +173 -0
- package/dist/migration-fence-suite.js.map +1 -0
- package/dist/migration-fence.d.ts +42 -0
- package/dist/migration-fence.d.ts.map +1 -0
- package/dist/migration-fence.js +66 -0
- package/dist/migration-fence.js.map +1 -0
- package/dist/modules.d.ts +126 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +23 -0
- package/dist/modules.js.map +1 -1
- package/dist/orphan-sweep-suite.d.ts +23 -0
- package/dist/orphan-sweep-suite.d.ts.map +1 -0
- package/dist/orphan-sweep-suite.js +411 -0
- package/dist/orphan-sweep-suite.js.map +1 -0
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +19 -0
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +465 -10
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/scope-store-runner-suite.d.ts +46 -0
- package/dist/scope-store-runner-suite.d.ts.map +1 -0
- package/dist/scope-store-runner-suite.js +187 -0
- package/dist/scope-store-runner-suite.js.map +1 -0
- package/dist/scope-store-suite.d.ts.map +1 -1
- package/dist/scope-store-suite.js +55 -103
- package/dist/scope-store-suite.js.map +1 -1
- package/dist/stamps-module.d.ts +4 -0
- package/dist/stamps-module.d.ts.map +1 -1
- package/dist/store-driver-release-suite.d.ts +12 -0
- package/dist/store-driver-release-suite.d.ts.map +1 -0
- package/dist/store-driver-release-suite.js +561 -0
- package/dist/store-driver-release-suite.js.map +1 -0
- package/dist/upload-link-module.d.ts +172 -0
- package/dist/upload-link-module.d.ts.map +1 -0
- package/dist/upload-link-module.js +157 -0
- package/dist/upload-link-module.js.map +1 -0
- package/dist/upload-link-replay-suite.d.ts +3 -0
- package/dist/upload-link-replay-suite.d.ts.map +1 -0
- package/dist/upload-link-replay-suite.js +291 -0
- package/dist/upload-link-replay-suite.js.map +1 -0
- package/dist/upload-link-suite.d.ts +3 -0
- package/dist/upload-link-suite.d.ts.map +1 -0
- package/dist/upload-link-suite.js +485 -0
- package/dist/upload-link-suite.js.map +1 -0
- package/dist/vertical-events-suite.d.ts +8 -0
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +46 -4
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAqBL,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAqBL,KAAK,SAAS,EAsBf,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAkBL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAc9B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAuCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,aAAa,MACrB,OAAO,CAAC,OAAO,CAAC,QACb,SAAS,YACL,MAAM,KACf,OAAO,CAAC,IAAI,CAOd,CAAC;AAEF,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,EAC5C,IAAI,GAAE,qBAA0B,GAC/B,IAAI,CA8rVN"}
|
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
2
|
import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
|
|
3
|
-
import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
|
|
4
|
-
import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
|
|
3
|
+
import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, unheldMigrationDigest, } from '@substrat-run/contracts';
|
|
4
|
+
import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, promoteReviewDigest, promoteReviewRefusalOf, rebindReviewRefusalOf, createUlid, ulid, } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
|
|
6
6
|
import { testOperations } from './test-operations.js';
|
|
7
|
+
import { derivedDigestOf, heldMigrations } from './held-migrations.js';
|
|
7
8
|
/**
|
|
8
9
|
* A journal read whose every row is whole. A test that planted nothing has no business meeting
|
|
9
10
|
* an `UndecodablePlatformRequest` (#1637), so one fails here, naming why, rather than being
|
|
@@ -3853,7 +3854,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3853
3854
|
version,
|
|
3854
3855
|
manifestDigest: `man-${version}`,
|
|
3855
3856
|
permissionDigest: 'p',
|
|
3856
|
-
|
|
3857
|
+
...(await heldMigrations(mig)),
|
|
3857
3858
|
deploymentRef: null,
|
|
3858
3859
|
});
|
|
3859
3860
|
await host.admin.admitVersion(staff, id);
|
|
@@ -5236,6 +5237,113 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5236
5237
|
expect(await host.admin.openSubjectPayloads(staff, t1, s, [{ subjectId: subject, sealed: sealed }]))
|
|
5237
5238
|
.toEqual([null]);
|
|
5238
5239
|
});
|
|
5240
|
+
/** A vertical with one admitted version, served on `ref`, and a scope pinned to `scopeRef`. */
|
|
5241
|
+
const pinnedErasureFixture = async (scopeRef) => {
|
|
5242
|
+
const slug = `pin-${ulid().toLowerCase().slice(-8)}`;
|
|
5243
|
+
await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
5244
|
+
const versionId = ulid();
|
|
5245
|
+
const ref = `${slug}-serving`;
|
|
5246
|
+
await host.admin.publishVersion(staff, {
|
|
5247
|
+
id: versionId,
|
|
5248
|
+
verticalSlug: slug,
|
|
5249
|
+
version: `1.0.${versionId.slice(-4).toLowerCase()}`,
|
|
5250
|
+
manifestDigest: `m-${versionId}`,
|
|
5251
|
+
permissionDigest: 'p',
|
|
5252
|
+
migrationDigest: 'g',
|
|
5253
|
+
deploymentRef: ref,
|
|
5254
|
+
manifestJson: JSON.stringify({
|
|
5255
|
+
version: '1.0.0', entry: 'index.js', compatibilityDate: '2026-07-01',
|
|
5256
|
+
registry: { permissions: [], roles: [], entityGrants: [] },
|
|
5257
|
+
digests: { manifest: 'm', permission: 'p', migration: 'g' },
|
|
5258
|
+
}),
|
|
5259
|
+
});
|
|
5260
|
+
await host.admin.admitVersion(staff, versionId);
|
|
5261
|
+
const s = scopeId.parse(ulid());
|
|
5262
|
+
const subject = ulid();
|
|
5263
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: slug });
|
|
5264
|
+
const [sealed] = await host.admin.sealSubjectPayloads(staff, t1, s, [{ subjectId: subject, plaintext: 'private' }]);
|
|
5265
|
+
await host.admin.setVerticalServing(staff, slug, { ref, versionId, doClasses: ['ScopeDO'], migrationTag: 'v1' });
|
|
5266
|
+
await host.admin.setScopeServingRef(staff, t1, s, scopeRef ?? ref);
|
|
5267
|
+
const opened = () => host.admin.openSubjectPayloads(staff, t1, s, [{ subjectId: subject, sealed: sealed }]);
|
|
5268
|
+
return { slug, versionId, ref, s, subject, opened };
|
|
5269
|
+
};
|
|
5270
|
+
const redacted = [{ events: 0, intents: 0, jobRuns: 0, idempotencyResults: 0, intentIds: [],
|
|
5271
|
+
vertical: { verticalRows: [], hookRows: [], unreachedEntities: [] } }];
|
|
5272
|
+
it('an erasure claim fails when the serving pointer is not the pair it read, and the key stays (#2285)', async () => {
|
|
5273
|
+
const { versionId, ref, s, subject, opened } = await pinnedErasureFixture();
|
|
5274
|
+
const expected = { versionId: null, servingRef: ref, epoch: 0, copyCount: 0, servingPointer: { ref, versionId: ulid() } };
|
|
5275
|
+
await expectRefusal(host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, expected), 'precondition_failed');
|
|
5276
|
+
expect(await opened()).toEqual(['private']);
|
|
5277
|
+
expect((await host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, { ...expected, servingPointer: { ref, versionId } })).keyDestroyed).toBe(true);
|
|
5278
|
+
expect(await opened()).toEqual([null]);
|
|
5279
|
+
});
|
|
5280
|
+
it('a scope off its pin is fenced too: a pointer moved onto its script refuses the claim (#2285)', async () => {
|
|
5281
|
+
// The scope's own script is not the pointer's when the erasure reads it, so it runs no
|
|
5282
|
+
// pointer version. A rollback onto that script before the claim would make it run one.
|
|
5283
|
+
const { slug, versionId, ref, s, subject, opened } = await pinnedErasureFixture('old-script');
|
|
5284
|
+
const expected = { versionId: null, servingRef: 'old-script', epoch: 0, copyCount: 0, servingPointer: { ref, versionId } };
|
|
5285
|
+
await host.admin.setVerticalServing(staff, slug, { ref: 'old-script', versionId, doClasses: ['ScopeDO'], migrationTag: 'v1' });
|
|
5286
|
+
await expectRefusal(host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, expected), 'precondition_failed');
|
|
5287
|
+
expect(await opened()).toEqual(['private']);
|
|
5288
|
+
expect((await host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, { ...expected, servingPointer: { ref: 'old-script', versionId } })).keyDestroyed).toBe(true);
|
|
5289
|
+
});
|
|
5290
|
+
it('a direct shred with a fence claims it: a moved scope keeps the key, and the pair it read destroys it (#2285)', async () => {
|
|
5291
|
+
const { versionId, ref, s, subject, opened } = await pinnedErasureFixture();
|
|
5292
|
+
const fence = { versionId: null, servingRef: ref, epoch: 0, copyCount: 0, servingPointer: { ref, versionId: ulid() } };
|
|
5293
|
+
await expectRefusal(host.admin.shredSubject(staff, t1, s, subject, fence), 'precondition_failed');
|
|
5294
|
+
expect(await opened()).toEqual(['private']);
|
|
5295
|
+
expect(await host.admin.scopeErasureEpoch(staff, t1, s)).toBe(0);
|
|
5296
|
+
expect((await host.admin.shredSubject(staff, t1, s, subject, { ...fence, servingPointer: { ref, versionId } })).keyDestroyed)
|
|
5297
|
+
.toBe(true);
|
|
5298
|
+
expect(await opened()).toEqual([null]);
|
|
5299
|
+
// The claim moved the epoch every bind compares, as the orchestrated finalize's does.
|
|
5300
|
+
expect(await host.admin.scopeErasureEpoch(staff, t1, s)).toBe(1);
|
|
5301
|
+
});
|
|
5302
|
+
// #2286, L17: what the lifecycle reaches is every binding any version declared.
|
|
5303
|
+
it('keeps every scope-store binding a version declared, refuses a driver change at publish, and names none it cannot read', async () => {
|
|
5304
|
+
const slug = `bind-${ulid().toLowerCase().slice(-8)}`;
|
|
5305
|
+
await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
5306
|
+
const publish = (scopeStores, raw) => {
|
|
5307
|
+
const id = ulid();
|
|
5308
|
+
return host.admin.publishVersion(staff, {
|
|
5309
|
+
id,
|
|
5310
|
+
verticalSlug: slug,
|
|
5311
|
+
version: `1.0.${id.slice(-6).toLowerCase()}`,
|
|
5312
|
+
manifestDigest: `m-${id}`,
|
|
5313
|
+
permissionDigest: 'p',
|
|
5314
|
+
migrationDigest: 'g',
|
|
5315
|
+
deploymentRef: null,
|
|
5316
|
+
manifestJson: raw ?? JSON.stringify({
|
|
5317
|
+
version: '1.0.0', entry: 'index.js', compatibilityDate: '2026-07-01',
|
|
5318
|
+
registry: { permissions: [], roles: [], entityGrants: [] },
|
|
5319
|
+
digests: { manifest: 'm', permission: 'p', migration: 'g' },
|
|
5320
|
+
...(scopeStores ? { scopeStores } : {}),
|
|
5321
|
+
}),
|
|
5322
|
+
}).then(() => id);
|
|
5323
|
+
};
|
|
5324
|
+
await publish([{ binding: 'GRAPH', driver: 'graph' }, { binding: 'OLD', driver: 'graph' }]);
|
|
5325
|
+
await publish([{ binding: 'GRAPH', driver: 'graph' }]);
|
|
5326
|
+
// A dropped binding is still there.
|
|
5327
|
+
expect(await host.admin.verticalScopeStores(staff, slug)).toEqual([
|
|
5328
|
+
{ binding: 'GRAPH', driver: 'graph' },
|
|
5329
|
+
{ binding: 'OLD', driver: 'graph' },
|
|
5330
|
+
]);
|
|
5331
|
+
// A driver change is refused, and the version is not stored.
|
|
5332
|
+
const before = (await host.admin.listVersions(staff, slug)).length;
|
|
5333
|
+
await expectRefusal(publish([{ binding: 'NEW', driver: 'graph' }, { binding: 'OLD', driver: 'counter' }]), 'conflict');
|
|
5334
|
+
expect((await host.admin.listVersions(staff, slug)).length).toBe(before);
|
|
5335
|
+
// So is a version that declares one binding twice.
|
|
5336
|
+
await expectRefusal(publish([{ binding: 'NEW', driver: 'graph' }, { binding: 'NEW', driver: 'graph' }]), 'conflict');
|
|
5337
|
+
expect((await host.admin.listVersions(staff, slug)).length).toBe(before);
|
|
5338
|
+
// The twin: the same driver again, with a new binding beside it, is published.
|
|
5339
|
+
await publish([{ binding: 'OLD', driver: 'graph' }, { binding: 'NEW', driver: 'graph' }]);
|
|
5340
|
+
expect((await host.admin.verticalScopeStores(staff, slug)).map((s) => s.binding)).toEqual(['GRAPH', 'NEW', 'OLD']);
|
|
5341
|
+
// A version whose manifest cannot be read may declare a store nobody can name.
|
|
5342
|
+
await publish(undefined, '{nope');
|
|
5343
|
+
await expectRefusal(host.admin.verticalScopeStores(staff, slug), 'unavailable');
|
|
5344
|
+
// An unknown vertical declared none.
|
|
5345
|
+
expect(await host.admin.verticalScopeStores(staff, `none-${slug}`)).toEqual([]);
|
|
5346
|
+
});
|
|
5239
5347
|
// #1722 (Codex #2008 r7): the admin-log record of a staff resolution of a kept copy. The
|
|
5240
5348
|
// resolution itself runs in the vertical's store; the directory only records it, in full.
|
|
5241
5349
|
it('records a kept-copy resolution in the admin log, before and after, and refuses an unknown scope', async () => {
|
|
@@ -5744,7 +5852,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5744
5852
|
const id = ulid();
|
|
5745
5853
|
await host.admin.publishVersion(staff, {
|
|
5746
5854
|
id, verticalSlug: 'migrationsapart', version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm',
|
|
5747
|
-
permissionDigest: 'p',
|
|
5855
|
+
permissionDigest: 'p', deploymentRef: null,
|
|
5856
|
+
// #2339: a set it carries is hashed as the host derives it; the others store none to check.
|
|
5857
|
+
migrationDigest: typeof manifest === 'string' || !Array.isArray(manifest.migrations) ? 'g' : await derivedDigestOf(manifest),
|
|
5748
5858
|
manifestJson: typeof manifest === 'string' ? manifest : JSON.stringify(manifest),
|
|
5749
5859
|
});
|
|
5750
5860
|
return id;
|
|
@@ -5778,6 +5888,60 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5778
5888
|
const junk = await publish('not json');
|
|
5779
5889
|
expect(await host.admin.versionManifest(staff, 'migrationsapart', junk)).toBe('not json');
|
|
5780
5890
|
expect(await host.admin.versionMigrations(staff, 'migrationsapart', junk)).toBeNull();
|
|
5891
|
+
// #2322: each migration's declared moves and discards are stored beside its SQL and read
|
|
5892
|
+
// back as published, whatever digest the publisher claimed; one declaring none has neither.
|
|
5893
|
+
const declared = [
|
|
5894
|
+
{ ...migrations[0], moves: { legacy_a: 'a' } },
|
|
5895
|
+
{ ...migrations[1] },
|
|
5896
|
+
{ ...migrations[2], discards: { 'b.c': 'replaced by d; see "0003"' } },
|
|
5897
|
+
];
|
|
5898
|
+
const withDeclarations = await publish({ ...base, migrations: declared });
|
|
5899
|
+
expect(await host.admin.versionMigrations(staff, 'migrationsapart', withDeclarations)).toEqual(declared);
|
|
5900
|
+
// A declaration the push boundary refuses (a discard with no reason) carries no SQL at all.
|
|
5901
|
+
const reasonless = await publish({ ...base, migrations: [{ ...migrations[0], discards: { a: '' } }] });
|
|
5902
|
+
expect(await host.admin.versionMigrations(staff, 'migrationsapart', reasonless)).toBeNull();
|
|
5903
|
+
});
|
|
5904
|
+
it("derives a version's migration digest from what it stores, and refuses a publish whose digest is not that (#2339)", async () => {
|
|
5905
|
+
const slug = `acme/digest-${ulid().slice(-8).toLowerCase()}`;
|
|
5906
|
+
await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
5907
|
+
const A = [{ moduleId: 'helpdesk', version: '0001-init', sql: 'CREATE TABLE a (id TEXT);' }];
|
|
5908
|
+
const B = [...A, { moduleId: 'helpdesk', version: '0002-drop', sql: 'DROP TABLE a;' }];
|
|
5909
|
+
const declaredA = [{ ...A[0], discards: { 'a.id': 'nothing reads it' } }];
|
|
5910
|
+
const publish = async (migrationDigest, migrations, doClasses) => {
|
|
5911
|
+
const id = ulid();
|
|
5912
|
+
await host.admin.publishVersion(staff, {
|
|
5913
|
+
id, verticalSlug: slug, version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm', permissionDigest: 'p',
|
|
5914
|
+
migrationDigest, deploymentRef: null,
|
|
5915
|
+
...(migrations ? { manifestJson: JSON.stringify({ migrations, ...(doClasses ? { doClasses } : {}) }) } : {}),
|
|
5916
|
+
});
|
|
5917
|
+
return id;
|
|
5918
|
+
};
|
|
5919
|
+
const digestOfA = await derivedDigestOf({ migrations: A });
|
|
5920
|
+
const a = await publish(digestOfA, A);
|
|
5921
|
+
expect((await host.admin.getVersion(staff, a, slug))?.migrationDigest).toBe(digestOfA);
|
|
5922
|
+
// The base's digest over other SQL, over the same SQL with a declared loss added, or over other
|
|
5923
|
+
// store classes: refused, and nothing is stored.
|
|
5924
|
+
for (const [migrations, doClasses] of [[B, undefined], [declaredA, undefined], [A, ['OtherDO']]]) {
|
|
5925
|
+
const before = (await host.admin.listVersions(staff, slug)).length;
|
|
5926
|
+
await expectRefusal(publish(digestOfA, [...migrations], doClasses ? [...doClasses] : undefined), 'validation_failed', /is not the digest of the migrations it carries/);
|
|
5927
|
+
expect(await host.admin.listVersions(staff, slug)).toHaveLength(before);
|
|
5928
|
+
}
|
|
5929
|
+
// Its positive twin: the digest B's own migrations give is stored, and a promote over A asks.
|
|
5930
|
+
const b = await publish(await derivedDigestOf({ migrations: B }), B);
|
|
5931
|
+
await host.admin.admitVersion(staff, a);
|
|
5932
|
+
await host.admin.admitVersion(staff, b);
|
|
5933
|
+
await host.admin.promoteVersion(staff, slug, 'prod', a);
|
|
5934
|
+
await expectRefusal(host.admin.promoteVersion(staff, slug, 'prod', b), 'conflict', /changes migrations/);
|
|
5935
|
+
// A version that stores none cannot be checked: never refused, and it equals no other version.
|
|
5936
|
+
const bare = await publish(digestOfA);
|
|
5937
|
+
expect((await host.admin.getVersion(staff, bare, slug))?.migrationDigest).toBe(unheldMigrationDigest(bare));
|
|
5938
|
+
// The admin log records the digest the registry holds, not the one the push sent; so does a held one's.
|
|
5939
|
+
const published = async (id) => (await host.admin.auditLog(staff, {})).find((e) => e.action === 'publishVersion' && e.after?.id === id)
|
|
5940
|
+
?.after?.migrationDigest;
|
|
5941
|
+
expect(await published(bare)).toBe(unheldMigrationDigest(bare));
|
|
5942
|
+
expect(await published(a)).toBe(digestOfA);
|
|
5943
|
+
await host.admin.admitVersion(staff, bare);
|
|
5944
|
+
await expectRefusal(host.admin.promoteVersion(staff, slug, 'prod', bare), 'conflict', /holds no migrations the platform can compare/);
|
|
5781
5945
|
});
|
|
5782
5946
|
it('an archived scope blocks the delete naming the reap step; a reaped tombstone never blocks', async () => {
|
|
5783
5947
|
// A deleted app leaves an `archived` row (restorable via unarchive), then a
|
|
@@ -5885,12 +6049,24 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5885
6049
|
version,
|
|
5886
6050
|
manifestDigest: `man-${version}`,
|
|
5887
6051
|
permissionDigest: digests.perm,
|
|
5888
|
-
|
|
6052
|
+
...(await heldMigrations(digests.mig)),
|
|
5889
6053
|
deploymentRef: null,
|
|
5890
6054
|
});
|
|
5891
6055
|
await host.admin.admitVersion(staff, id);
|
|
5892
6056
|
return id;
|
|
5893
6057
|
};
|
|
6058
|
+
/**
|
|
6059
|
+
* #2333: the binding a review hands out, computed as the migrations read computes it — from
|
|
6060
|
+
* the stored rows of the version prod points at now and of the one being promoted.
|
|
6061
|
+
*/
|
|
6062
|
+
const reviewOf = async (slug, versionId, base) => {
|
|
6063
|
+
const prod = base !== undefined ? base : ((await host.admin.listChannels(staff, slug)).find((c) => c.channel === 'prod')?.versionId ?? null);
|
|
6064
|
+
const side = async (id) => ({
|
|
6065
|
+
...(await host.admin.getVersion(staff, id, slug)),
|
|
6066
|
+
migrations: await host.admin.versionMigrations(staff, slug, id),
|
|
6067
|
+
});
|
|
6068
|
+
return { base: prod, digest: await promoteReviewDigest(prod === null ? null : await side(prod), await side(versionId)) };
|
|
6069
|
+
};
|
|
5894
6070
|
it('promotes a first version with nothing to acknowledge', async () => {
|
|
5895
6071
|
// The gate is about CHANGE, not existence. A first promotion has no
|
|
5896
6072
|
// predecessor to diff against, so demanding an acknowledgement would be
|
|
@@ -5913,7 +6089,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5913
6089
|
it('refuses a promotion that changes migrations until it is acknowledged', async () => {
|
|
5914
6090
|
const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
|
|
5915
6091
|
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3), 'conflict', /changes migrations/);
|
|
5916
|
-
|
|
6092
|
+
// #2333: the acknowledgement that lets a migration reach live data names the review it answers.
|
|
6093
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true }), 'precondition_failed', /needs the review it answers/);
|
|
6094
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true, review: await reviewOf('callout', v3) });
|
|
5917
6095
|
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
|
|
5918
6096
|
?.versionId).toBe(v3);
|
|
5919
6097
|
});
|
|
@@ -5950,9 +6128,259 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5950
6128
|
await expectRefusal(host.admin.promoteVersion(staff, 'elsewhere', 'prod', v), 'conflict');
|
|
5951
6129
|
// Positive twin: through its own vertical the same version promotes (the earlier tests
|
|
5952
6130
|
// moved prod, so the digest checkpoints are acknowledged — they are not under test here).
|
|
5953
|
-
await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true });
|
|
6131
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true, review: await reviewOf('callout', v) });
|
|
5954
6132
|
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId).toBe(v);
|
|
5955
6133
|
});
|
|
6134
|
+
// -- the acknowledgement names the review it answers (#2333) ---------------
|
|
6135
|
+
// A review is computed over the channel's CURRENT version. A promote between showing it and
|
|
6136
|
+
// acknowledging it moves that base, and a bare flag would then acknowledge a diff that is not
|
|
6137
|
+
// the one applied. So the acknowledgement carries the review's binding, and the host refuses
|
|
6138
|
+
// one that no longer holds.
|
|
6139
|
+
describe('an acknowledgement bound to the review it answers (#2333)', () => {
|
|
6140
|
+
const MIGRATION = (discards, moves = {}) => ({
|
|
6141
|
+
moduleId: 'callout', version: '0002-drop', sql: 'ALTER TABLE t DROP COLUMN c;', discards, ...(Object.keys(moves).length ? { moves } : {}),
|
|
6142
|
+
});
|
|
6143
|
+
/** A fresh vertical with three admitted versions; `a` serves. */
|
|
6144
|
+
const fresh = async () => {
|
|
6145
|
+
const slug = `acme/review-${ulid().slice(-8).toLowerCase()}`;
|
|
6146
|
+
await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
6147
|
+
const put = async (perm, mig, migrations) => {
|
|
6148
|
+
const id = ulid();
|
|
6149
|
+
await host.admin.publishVersion(staff, {
|
|
6150
|
+
id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
|
|
6151
|
+
// #2339: the digest is the one the stored migrations give; `mig` names the set without them.
|
|
6152
|
+
permissionDigest: perm, deploymentRef: null,
|
|
6153
|
+
...(migrations
|
|
6154
|
+
? { migrationDigest: await derivedDigestOf({ migrations }), manifestJson: JSON.stringify({ migrations }) }
|
|
6155
|
+
: { migrationDigest: mig }),
|
|
6156
|
+
});
|
|
6157
|
+
await host.admin.admitVersion(staff, id);
|
|
6158
|
+
return id;
|
|
6159
|
+
};
|
|
6160
|
+
const a = await put('pA', 'gA', []);
|
|
6161
|
+
await host.admin.promoteVersion(staff, slug, 'prod', a);
|
|
6162
|
+
return { slug, put, a };
|
|
6163
|
+
};
|
|
6164
|
+
const prodOf = async (slug) => (await host.admin.listChannels(staff, slug)).find((c) => c.channel === 'prod')?.versionId;
|
|
6165
|
+
const refusal = async (p) => {
|
|
6166
|
+
const err = await p.then(() => undefined, (e) => e);
|
|
6167
|
+
return { kind: promoteReviewRefusalOf(err), message: err?.message };
|
|
6168
|
+
};
|
|
6169
|
+
it('a promote between show and acknowledge moves the base: the acknowledgement is refused, and a fresh review passes', async () => {
|
|
6170
|
+
const { slug, put, a } = await fresh();
|
|
6171
|
+
const c = await put('pC', 'gC', [MIGRATION({ c: 'the column is no longer read' })]);
|
|
6172
|
+
const shown = await reviewOf(slug, c);
|
|
6173
|
+
expect(shown.base).toBe(a);
|
|
6174
|
+
// Someone else promotes b in between: same migrations as a, a different permission surface.
|
|
6175
|
+
const b = await put('pB', 'gA', []);
|
|
6176
|
+
await host.admin.promoteVersion(staff, slug, 'prod', b, { permissionChange: true, review: await reviewOf(slug, b) });
|
|
6177
|
+
const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { permissionChange: true, migrationChange: true, review: shown }));
|
|
6178
|
+
expect(stale.kind).toBe('stale');
|
|
6179
|
+
expect(stale.message).toContain(`read against version ${a}`);
|
|
6180
|
+
expect(await prodOf(slug)).toBe(b);
|
|
6181
|
+
// The review read again names b, and is what an acknowledgement answers now.
|
|
6182
|
+
const again = await reviewOf(slug, c);
|
|
6183
|
+
expect(again).toEqual({ base: b, digest: expect.stringMatching(/^[0-9a-f]{64}$/) });
|
|
6184
|
+
expect(again.digest).not.toBe(shown.digest);
|
|
6185
|
+
await host.admin.promoteVersion(staff, slug, 'prod', c, { permissionChange: true, migrationChange: true, review: again });
|
|
6186
|
+
expect(await prodOf(slug)).toBe(c);
|
|
6187
|
+
});
|
|
6188
|
+
it('two promotes bound to the same base, racing: one moves the channel, the other is refused as stale', async () => {
|
|
6189
|
+
const { slug, put, a } = await fresh();
|
|
6190
|
+
const b = await put('pB', 'gB', [MIGRATION({ c: 'gone' })]);
|
|
6191
|
+
const c = await put('pC', 'gC', [MIGRATION({ c: 'gone too' })]);
|
|
6192
|
+
const [forB, forC] = [await reviewOf(slug, b), await reviewOf(slug, c)];
|
|
6193
|
+
expect([forB.base, forC.base]).toEqual([a, a]);
|
|
6194
|
+
const ACK = { permissionChange: true, migrationChange: true };
|
|
6195
|
+
// Neither awaited before the other starts: each judges its gates against `a`.
|
|
6196
|
+
const settled = await Promise.allSettled([
|
|
6197
|
+
host.admin.promoteVersion(staff, slug, 'prod', b, { ...ACK, review: forB }),
|
|
6198
|
+
host.admin.promoteVersion(staff, slug, 'prod', c, { ...ACK, review: forC }),
|
|
6199
|
+
]);
|
|
6200
|
+
const won = settled.flatMap((s, i) => (s.status === 'fulfilled' ? [[b, c][i]] : []));
|
|
6201
|
+
const lost = settled.flatMap((s) => (s.status === 'rejected' ? [s.reason] : []));
|
|
6202
|
+
expect(won).toHaveLength(1);
|
|
6203
|
+
expect(lost.map((e) => promoteReviewRefusalOf(e))).toEqual(['stale']);
|
|
6204
|
+
expect(await prodOf(slug)).toBe(won[0]);
|
|
6205
|
+
// One move away from `a`, by the winner; the loser recorded nothing.
|
|
6206
|
+
const history = await host.admin.listChannelHistory(staff, slug, 'prod');
|
|
6207
|
+
expect(history.filter((h) => h.fromVersionId === a).map((h) => h.versionId)).toEqual(won);
|
|
6208
|
+
expect(history.some((h) => h.versionId === (won[0] === b ? c : b))).toBe(false);
|
|
6209
|
+
});
|
|
6210
|
+
it('a binding is checked whatever it acknowledges: a moved base refuses even a promote that needs no acknowledgement', async () => {
|
|
6211
|
+
const { slug, put, a } = await fresh();
|
|
6212
|
+
const same = await put('pA', 'gA', []);
|
|
6213
|
+
const shown = await reviewOf(slug, same);
|
|
6214
|
+
const b = await put('pB', 'gA', []);
|
|
6215
|
+
await host.admin.promoteVersion(staff, slug, 'prod', b, { permissionChange: true });
|
|
6216
|
+
expect((await refusal(host.admin.promoteVersion(staff, slug, 'prod', same, { review: shown }))).kind).toBe('stale');
|
|
6217
|
+
expect(await prodOf(slug)).toBe(b);
|
|
6218
|
+
// Twin: bound to the base that still serves, it promotes.
|
|
6219
|
+
await host.admin.promoteVersion(staff, slug, 'prod', a, { permissionChange: true, review: await reviewOf(slug, a) });
|
|
6220
|
+
expect(await prodOf(slug)).toBe(a);
|
|
6221
|
+
});
|
|
6222
|
+
it('a review read before anything served is stale once something does', async () => {
|
|
6223
|
+
const slug = `acme/review-${ulid().slice(-8).toLowerCase()}`;
|
|
6224
|
+
await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
6225
|
+
const put = async () => {
|
|
6226
|
+
const id = ulid();
|
|
6227
|
+
await host.admin.publishVersion(staff, { id, verticalSlug: slug, version: id, manifestDigest: 'm', permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null });
|
|
6228
|
+
await host.admin.admitVersion(staff, id);
|
|
6229
|
+
return id;
|
|
6230
|
+
};
|
|
6231
|
+
const first = await put();
|
|
6232
|
+
const shown = await reviewOf(slug, first, null);
|
|
6233
|
+
expect(shown.base).toBeNull();
|
|
6234
|
+
const other = await put();
|
|
6235
|
+
await host.admin.promoteVersion(staff, slug, 'prod', other);
|
|
6236
|
+
const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', first, { review: shown }));
|
|
6237
|
+
expect(stale.kind).toBe('stale');
|
|
6238
|
+
expect(stale.message).toContain('read against no serving version');
|
|
6239
|
+
});
|
|
6240
|
+
it('a digest that is not the one the stored rows give is refused, with the base right', async () => {
|
|
6241
|
+
const { slug, put, a } = await fresh();
|
|
6242
|
+
const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' })]);
|
|
6243
|
+
const shown = await reviewOf(slug, c);
|
|
6244
|
+
// Another review's digest, as a client that bound the wrong answer would send.
|
|
6245
|
+
const other = await reviewOf(slug, await put('pA', 'gD', [MIGRATION({ c: 'gone, differently' })]));
|
|
6246
|
+
expect(other.base).toBe(a);
|
|
6247
|
+
const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: { base: a, digest: other.digest } }));
|
|
6248
|
+
expect(stale.kind).toBe('stale');
|
|
6249
|
+
expect(stale.message).toMatch(/no longer give the digest/);
|
|
6250
|
+
// Never handed back: a refusal is not something to retry with.
|
|
6251
|
+
expect(stale.message).not.toContain(shown.digest);
|
|
6252
|
+
expect(await prodOf(slug)).toBe(a);
|
|
6253
|
+
await host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: shown });
|
|
6254
|
+
expect(await prodOf(slug)).toBe(c);
|
|
6255
|
+
});
|
|
6256
|
+
it('a migration acknowledgement without a binding is refused; a permission-only one is still honoured', async () => {
|
|
6257
|
+
const { slug, put } = await fresh();
|
|
6258
|
+
const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' })]);
|
|
6259
|
+
const unbound = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true }));
|
|
6260
|
+
expect(unbound.kind).toBe('unbound');
|
|
6261
|
+
// It names the fix: the client release that binds, and how to read the review.
|
|
6262
|
+
// By what the release does, never a version number the release tooling has not cut yet.
|
|
6263
|
+
expect(unbound.message).toContain("a CLI release whose 'substrat promote' supports --review");
|
|
6264
|
+
expect(unbound.message).toContain('/guide/deploying#acknowledgement-answers-one-review');
|
|
6265
|
+
expect(unbound.message).not.toMatch(/\d+\.\d+\.\d+/);
|
|
6266
|
+
// The compatibility rule: no migration moves, so an older client's bare flag still promotes.
|
|
6267
|
+
const p = await put('pP', 'gA', []);
|
|
6268
|
+
await host.admin.promoteVersion(staff, slug, 'prod', p, { permissionChange: true });
|
|
6269
|
+
expect(await prodOf(slug)).toBe(p);
|
|
6270
|
+
// And a migrationChange the gate does not consume needs no binding either.
|
|
6271
|
+
const q = await put('pP', 'gA', []);
|
|
6272
|
+
await host.admin.promoteVersion(staff, slug, 'prod', q, { migrationChange: true });
|
|
6273
|
+
expect(await prodOf(slug)).toBe(q);
|
|
6274
|
+
});
|
|
6275
|
+
it('the digest is over the stored declarations, and the binding is recorded', async () => {
|
|
6276
|
+
const { slug, put, a } = await fresh();
|
|
6277
|
+
const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' }, { 't.a': 't.b' })]);
|
|
6278
|
+
const shown = await reviewOf(slug, c);
|
|
6279
|
+
// What the host digests is what it stored: the declarations come back with the SQL…
|
|
6280
|
+
const stored = await host.admin.versionMigrations(staff, slug, c);
|
|
6281
|
+
expect(stored).toEqual([MIGRATION({ c: 'gone' }, { 't.a': 't.b' })]);
|
|
6282
|
+
// …and a review of the same version showing a reworded reason binds another digest.
|
|
6283
|
+
const side = async (id, migrations) => ({ ...(await host.admin.getVersion(staff, id, slug)), migrations: migrations });
|
|
6284
|
+
const reworded = await promoteReviewDigest(await side(a, await host.admin.versionMigrations(staff, slug, a)), await side(c, [MIGRATION({ c: 'gone, differently' }, { 't.a': 't.b' })]));
|
|
6285
|
+
expect(reworded).not.toBe(shown.digest);
|
|
6286
|
+
expect((await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: { base: a, digest: reworded } }))).kind).toBe('stale');
|
|
6287
|
+
await host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: shown });
|
|
6288
|
+
const row = (await host.admin.auditLog(staff, {})).find((e) => e.action === 'promoteVersion' && JSON.stringify(e.after).includes(c));
|
|
6289
|
+
expect(row?.after?.acknowledged).toEqual({ migrationChange: true, review: shown });
|
|
6290
|
+
});
|
|
6291
|
+
});
|
|
6292
|
+
// -- a rebind's route and pointer move in one write (#2347) ----------------
|
|
6293
|
+
// A rebind onto another lineage is judged against the version the scope is bound to and the
|
|
6294
|
+
// version the target serves. Flipping the route and crossing the pointer used to be two
|
|
6295
|
+
// writes, so a promote on the target between them left the route on the new script and the
|
|
6296
|
+
// pointer on the old lineage. Now they are one write, conditional on both sides.
|
|
6297
|
+
describe("a rebind's route and pointer move in one write, conditional on both sides (#2347)", () => {
|
|
6298
|
+
const lineages = async () => {
|
|
6299
|
+
const tag = ulid().slice(-8).toLowerCase();
|
|
6300
|
+
const [source, target] = [`acme/src-${tag}`, `acme/dst-${tag}`];
|
|
6301
|
+
const put = async (slug) => {
|
|
6302
|
+
const id = ulid();
|
|
6303
|
+
await host.admin.publishVersion(staff, {
|
|
6304
|
+
id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: 'm', permissionDigest: 'p',
|
|
6305
|
+
migrationDigest: 'g', deploymentRef: null,
|
|
6306
|
+
});
|
|
6307
|
+
await host.admin.admitVersion(staff, id);
|
|
6308
|
+
return id;
|
|
6309
|
+
};
|
|
6310
|
+
for (const slug of [source, target])
|
|
6311
|
+
await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli', ownerTenant: t1 });
|
|
6312
|
+
const [s1, s2, t1v, t2v] = [await put(source), await put(source), await put(target), await put(target)];
|
|
6313
|
+
const ref = `serving-${tag}`;
|
|
6314
|
+
await host.admin.setVerticalServing(staff, target, { ref, versionId: t1v, doClasses: [], migrationTag: 'g' });
|
|
6315
|
+
const install = async () => {
|
|
6316
|
+
const sc = scopeId.parse(ulid());
|
|
6317
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: sc, vertical: source });
|
|
6318
|
+
await host.admin.activateScope(staff, t1, sc);
|
|
6319
|
+
await host.admin.bindScopeVersion(staff, t1, sc, s1);
|
|
6320
|
+
return sc;
|
|
6321
|
+
};
|
|
6322
|
+
const judged = { vertical: target, ref, versionId: t1v };
|
|
6323
|
+
return { source, target, s1, s2, t1v, t2v, ref, install, judged };
|
|
6324
|
+
};
|
|
6325
|
+
const where = async (sc) => {
|
|
6326
|
+
const r = (await host.admin.getScopeRecord(staff, t1, sc));
|
|
6327
|
+
return { vertical: r.vertical, versionId: r.verticalVersionId, servingRef: r.servingRef ?? null };
|
|
6328
|
+
};
|
|
6329
|
+
const refusal = (p) => p.then(() => undefined, (e) => e);
|
|
6330
|
+
it('lands route and pointer together when both sides still hold, and records both', async () => {
|
|
6331
|
+
const { source, target, s1, t1v, ref, install, judged } = await lineages();
|
|
6332
|
+
const sc = await install();
|
|
6333
|
+
expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
|
|
6334
|
+
await host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged });
|
|
6335
|
+
expect(await where(sc)).toEqual({ vertical: target, versionId: t1v, servingRef: ref });
|
|
6336
|
+
const row = (await host.admin.auditLog(staff, { action: 'bindScopeVersion', scopeId: sc }))
|
|
6337
|
+
.find((e) => e.after?.versionId === t1v);
|
|
6338
|
+
expect(row?.before).toEqual({ servingRef: null });
|
|
6339
|
+
expect(row?.after).toMatchObject({ versionId: t1v, servingRef: ref, expectedVersionId: s1, expectedServing: judged });
|
|
6340
|
+
});
|
|
6341
|
+
it('a promote on the TARGET since the rebind was judged refuses it as stale, and moves neither route nor pointer', async () => {
|
|
6342
|
+
const { source, target, s1, t1v, t2v, ref, install, judged } = await lineages();
|
|
6343
|
+
const sc = await install();
|
|
6344
|
+
// What the target serves moves on after the rebind read it.
|
|
6345
|
+
await host.admin.setVerticalServing(staff, target, { ref, versionId: t2v, doClasses: [], migrationTag: 'g' });
|
|
6346
|
+
const err = await refusal(host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }));
|
|
6347
|
+
expect(rebindReviewRefusalOf(err)).toBe('stale');
|
|
6348
|
+
expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
|
|
6349
|
+
// The twin: judged against what serves now, it lands.
|
|
6350
|
+
await host.admin.bindScopeVersion(staff, t1, sc, t2v, { servingRef: ref, expectedVersionId: s1, expectedServing: { ...judged, versionId: t2v } });
|
|
6351
|
+
expect(await where(sc)).toEqual({ vertical: target, versionId: t2v, servingRef: ref });
|
|
6352
|
+
});
|
|
6353
|
+
it('a move of the SOURCE binding since the rebind was judged refuses it as stale; a plain conditional bind keeps its own refusal', async () => {
|
|
6354
|
+
const { source, s1, s2, t1v, ref, install, judged } = await lineages();
|
|
6355
|
+
const sc = await install();
|
|
6356
|
+
await host.admin.bindScopeVersion(staff, t1, sc, s2);
|
|
6357
|
+
const err = await refusal(host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }));
|
|
6358
|
+
expect(rebindReviewRefusalOf(err)).toBe('stale');
|
|
6359
|
+
expect(await where(sc)).toEqual({ vertical: source, versionId: s2, servingRef: null });
|
|
6360
|
+
// Without `expectedServing` the bind is the #1722 compare-and-set it always was.
|
|
6361
|
+
const plain = await refusal(host.admin.bindScopeVersion(staff, t1, sc, s1, { expectedVersionId: s1 }));
|
|
6362
|
+
expect(rebindReviewRefusalOf(plain)).toBeNull();
|
|
6363
|
+
expect(String(plain)).toMatch(/scope binding changed/);
|
|
6364
|
+
});
|
|
6365
|
+
it('racing a promote on the target: either the rebind lands whole, or it is refused as stale and nothing moved', async () => {
|
|
6366
|
+
const { source, target, s1, t1v, t2v, ref, install, judged } = await lineages();
|
|
6367
|
+
for (let round = 0; round < 4; round++) {
|
|
6368
|
+
const sc = await install();
|
|
6369
|
+
await host.admin.setVerticalServing(staff, target, { ref, versionId: t1v, doClasses: [], migrationTag: 'g' });
|
|
6370
|
+
const [rebind] = await Promise.allSettled([
|
|
6371
|
+
host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }),
|
|
6372
|
+
host.admin.setVerticalServing(staff, target, { ref, versionId: t2v, doClasses: [], migrationTag: 'g' }),
|
|
6373
|
+
]);
|
|
6374
|
+
if (rebind.status === 'fulfilled') {
|
|
6375
|
+
expect(await where(sc)).toEqual({ vertical: target, versionId: t1v, servingRef: ref });
|
|
6376
|
+
}
|
|
6377
|
+
else {
|
|
6378
|
+
expect(rebindReviewRefusalOf(rebind.reason)).toBe('stale');
|
|
6379
|
+
expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
|
|
6380
|
+
}
|
|
6381
|
+
}
|
|
6382
|
+
});
|
|
6383
|
+
});
|
|
5956
6384
|
// -- a version that strands people a link bound (#2178) -------------------
|
|
5957
6385
|
// A binding shape's people keep it until they are released, and the levers need the shape:
|
|
5958
6386
|
// an unbind needs it to bind, a retirement needs it to be a bootstrap shape, an erasure finds
|
|
@@ -5979,8 +6407,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5979
6407
|
const id = ulid();
|
|
5980
6408
|
await host.admin.publishVersion(staff, {
|
|
5981
6409
|
id, verticalSlug: slug, version: `3.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
|
|
5982
|
-
permissionDigest: 'p',
|
|
5983
|
-
|
|
6410
|
+
permissionDigest: 'p', deploymentRef: null,
|
|
6411
|
+
...(await heldMigrations('g', { registry: { permissions: [], roles: [], entityGrants } })),
|
|
5984
6412
|
});
|
|
5985
6413
|
if (admit)
|
|
5986
6414
|
await host.admin.admitVersion(staff, id);
|
|
@@ -6078,6 +6506,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6078
6506
|
await host.admin.setScopeServingRef(staff, t1, sc, ref);
|
|
6079
6507
|
expect(await routeOf()).toBe(ref);
|
|
6080
6508
|
});
|
|
6509
|
+
it('a bind that routes the scope in the same write (#2347) is judged on the script it routes to', async () => {
|
|
6510
|
+
const { slug, v } = await listed();
|
|
6511
|
+
const sc = await install(slug, v.binds);
|
|
6512
|
+
const ref = `serving-${ulid().slice(-8).toLowerCase()}`;
|
|
6513
|
+
await host.admin.setVerticalServing(staff, slug, { ref, versionId: v.dropped, doClasses: [], migrationTag: 'g' });
|
|
6514
|
+
// The pointer stays on the binding version; the route moves onto a script that runs one
|
|
6515
|
+
// that drops the shape. Judged without the route, this bind would pass.
|
|
6516
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, sc, v.binds, { servingRef: ref }), 'precondition_failed', /drops the shape/);
|
|
6517
|
+
const r = await host.admin.getScopeRecord(staff, t1, sc);
|
|
6518
|
+
expect([r?.verticalVersionId, r?.servingRef ?? null]).toEqual([v.binds, null]);
|
|
6519
|
+
// The twin: the script serves a version that retires the keys, and both move in one write.
|
|
6520
|
+
await host.admin.setVerticalServing(staff, slug, { ref, versionId: v.retired, doClasses: [], migrationTag: 'g' });
|
|
6521
|
+
await host.admin.bindScopeVersion(staff, t1, sc, v.binds, { servingRef: ref });
|
|
6522
|
+
const after = await host.admin.getScopeRecord(staff, t1, sc);
|
|
6523
|
+
expect([after?.verticalVersionId, after?.servingRef]).toEqual([v.binds, ref]);
|
|
6524
|
+
});
|
|
6081
6525
|
it('a promote is judged from the version served in place too, and replacing what serves is judged like a promote (review of #2181)', async () => {
|
|
6082
6526
|
const { slug, v } = await listed();
|
|
6083
6527
|
const ref = `serving-${ulid().slice(-8).toLowerCase()}`;
|
|
@@ -6467,7 +6911,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6467
6911
|
version,
|
|
6468
6912
|
manifestDigest: `man-${version}`,
|
|
6469
6913
|
permissionDigest: digests?.perm ?? 'pp',
|
|
6470
|
-
|
|
6914
|
+
...(await heldMigrations(digests?.mig ?? 'gg')),
|
|
6471
6915
|
deploymentRef: null,
|
|
6472
6916
|
});
|
|
6473
6917
|
return id;
|
|
@@ -6916,6 +7360,17 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6916
7360
|
const reclaimed = (await host.admin.listHostnames(staff, { scopeId: s2 })).find((h) => h.hostname === 'eu.example.com');
|
|
6917
7361
|
expect(reclaimed?.scopeId).toBe(s2);
|
|
6918
7362
|
});
|
|
7363
|
+
it('an unbind for one scope leaves a name rebound to another scope bound (#2285)', async () => {
|
|
7364
|
+
// `eu.example.com` is s2's now (the test above). An unroute of s1 that listed the name while
|
|
7365
|
+
// it was s1's must not take it from s2: the owner is judged by the delete itself.
|
|
7366
|
+
const s2Names = async () => (await host.admin.listHostnames(staff, { scopeId: s2 })).map((h) => h.hostname);
|
|
7367
|
+
await host.admin.unbindHostname(staff, 'eu.example.com', { tenantId: t1, scopeId: s1 });
|
|
7368
|
+
expect(await s2Names()).toContain('eu.example.com');
|
|
7369
|
+
// The twin: the owner it names unbinds it, and the unbind is audited on that scope.
|
|
7370
|
+
await host.admin.unbindHostname(staff, 'EU.example.com', { tenantId: t2, scopeId: s2 });
|
|
7371
|
+
expect(await s2Names()).not.toContain('eu.example.com');
|
|
7372
|
+
expect((await host.admin.auditLog(staff, { scopeId: s2 })).map((e) => e.action)).toContain('unbindHostname');
|
|
7373
|
+
});
|
|
6919
7374
|
it('rejects duplicate module registration', () => {
|
|
6920
7375
|
expect(() => host.registerModule({ manifest: testModManifest })).toThrow(/already registered/);
|
|
6921
7376
|
});
|