@substrat-run/contract-tests 0.23.0 → 0.25.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, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
3
+ import { connectionId, 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
  /**
@@ -561,6 +561,37 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
561
561
  const vCol = page.columns.indexOf('v');
562
562
  expect(page.rows.map((r) => r[vCol])).toContain('post-restore-write');
563
563
  });
564
+ it('accepts a dump missing kernel-spine tables — the spine is re-asserted, not left absent (#321)', async () => {
565
+ // A backup captured from a WORLD that stores some `_substrat_*` tables ELSEWHERE
566
+ // (an `@substrat-run/adapter-sqlite` scope file keeps roles/tuples in its DIRECTORY
567
+ // db), or simply a partial dump, omits spine tables the target must have. Before
568
+ // #321 the restore's frontier refresh read `_substrat_migrations` straight after the
569
+ // replay and raised a bare `no such table` → a detail-less 500. Restore must instead
570
+ // re-create the missing spine (empty) so the load completes.
571
+ //
572
+ // A dedicated throwaway scope: the stripped restore drops this scope's migration
573
+ // frontier, so it must not be one the rest of the shared suite depends on.
574
+ const spineScope = scopeId.parse(ulid());
575
+ await host.provisionScope(staff, {
576
+ tenantId: t1,
577
+ scopeId: spineScope,
578
+ jurisdiction: 'eu',
579
+ vertical: 'connector-vertical',
580
+ });
581
+ await host.admin.activateScope(staff, t1, spineScope);
582
+ const backup = await host.admin.exportScope(staff, t1, spineScope);
583
+ expect(backup.tables.some((t) => t.name === '_substrat_migrations')).toBe(true);
584
+ const stripped = {
585
+ ...backup,
586
+ tables: backup.tables.filter((t) => t.name !== '_substrat_migrations'),
587
+ };
588
+ // Pre-#321 this rejected (the frontier read hit `no such table`); now it completes.
589
+ await expect(host.restoreScope(staff, t1, spineScope, stripped)).resolves.toBeUndefined();
590
+ // The spine is present, not absent — introspection reads the scope without crashing,
591
+ // and the re-created `_substrat_migrations` is back.
592
+ const tables = await host.admin.listScopeTables(staff, t1, spineScope);
593
+ expect(tables.some((t) => t.name === '_substrat_migrations')).toBe(true);
594
+ });
564
595
  });
565
596
  // -- snapshot & auto-snapshot on bind (preview-and-snapshots.md §3/§4) -----
566
597
  //
@@ -1723,6 +1754,126 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1723
1754
  // s3 is active — you cannot un-archive an active scope.
1724
1755
  await expect(host.admin.unarchiveScope(staff, t3, s3)).rejects.toThrow(/illegal scope transition/);
1725
1756
  });
1757
+ // -- reap: the terminal storage wipe (control-plane.md §4.4) ---------------
1758
+ it('archive stamps archivedAt; unarchive clears it (§4.4)', async () => {
1759
+ const s = scopeId.parse(ulid());
1760
+ await host.provisionScope(staff, { tenantId: t3, scopeId: s, jurisdiction: 'eu' });
1761
+ await host.admin.activateScope(staff, t3, s);
1762
+ // Active: no archival timestamp yet.
1763
+ const [active] = await host.admin.listScopes(staff, { tenantId: t3, status: ['active'] }).then((rows) => rows.filter((r) => r.id === s));
1764
+ expect(active?.archivedAt).toBeNull();
1765
+ // Archiving stamps it…
1766
+ await host.admin.archiveScope(staff, t3, s);
1767
+ const archived = (await host.admin.getScopeRecord(staff, t3, s));
1768
+ expect(archived.status).toBe('archived');
1769
+ expect(archived.archivedAt).not.toBeNull();
1770
+ // …and un-archiving (a restore) clears it, so a later re-archive dates fresh.
1771
+ await host.admin.unarchiveScope(staff, t3, s);
1772
+ const restored = (await host.admin.getScopeRecord(staff, t3, s));
1773
+ expect(restored.archivedAt).toBeNull();
1774
+ });
1775
+ it('reapScope wipes storage, keeps the tombstone row, and fails getScope closed (§4.4)', async () => {
1776
+ const s = scopeId.parse(ulid());
1777
+ await host.provisionScope(staff, { tenantId: t3, scopeId: s, slug: 'reap-me', jurisdiction: 'eu' });
1778
+ await host.admin.activateScope(staff, t3, s);
1779
+ // Reap refuses a live (active) scope — only archived may be reaped.
1780
+ await expect(host.admin.reapScope(staff, t3, s)).rejects.toThrow(/not archived/);
1781
+ await host.admin.archiveScope(staff, t3, s);
1782
+ await host.admin.reapScope(staff, t3, s);
1783
+ // The directory row SURVIVES as a tombstone, now `reaped`.
1784
+ const rec = (await host.admin.getScopeRecord(staff, t3, s));
1785
+ expect(rec.status).toBe('reaped');
1786
+ // getScope fails closed on the reaped scope, exactly like a missing one.
1787
+ await expect(host.getScope(alice, t3, s)).rejects.toThrow(/scope not active|unknown scope/);
1788
+ // Audited as reapScope against the right scope + actor.
1789
+ const reapEntry = (await host.admin.auditLog(staff, { tenantId: t3 })).find((r) => r.action === 'reapScope' && r.scopeId === s);
1790
+ expect(reapEntry?.actor).toBe(staff);
1791
+ });
1792
+ it('a reaped scope releases its slug for reuse; reap is terminal (§4.4)', async () => {
1793
+ const s = scopeId.parse(ulid());
1794
+ await host.provisionScope(staff, { tenantId: t3, scopeId: s, slug: 'recyclable', jurisdiction: 'eu' });
1795
+ await host.admin.activateScope(staff, t3, s);
1796
+ await host.admin.archiveScope(staff, t3, s);
1797
+ await host.admin.reapScope(staff, t3, s);
1798
+ // The name is free again: a fresh scope may claim the reaped scope's slug.
1799
+ const fresh = scopeId.parse(ulid());
1800
+ await host.provisionScope(staff, { tenantId: t3, scopeId: fresh, slug: 'recyclable', jurisdiction: 'eu' });
1801
+ await host.admin.activateScope(staff, t3, fresh);
1802
+ await expect(host.getScope(alice, t3, fresh)).resolves.toBeDefined();
1803
+ // Terminal: a reaped scope cannot be unarchived (bytes are gone) or reaped again.
1804
+ await expect(host.admin.unarchiveScope(staff, t3, s)).rejects.toThrow(/illegal scope transition/);
1805
+ await expect(host.admin.reapScope(staff, t3, s)).rejects.toThrow(/not archived/);
1806
+ });
1807
+ // -- tenant delete lifecycle (control-plane.md §4.8) ----------------------
1808
+ it('delete stamps deletingAt and fails getScope closed; un-delete restores (§4.8)', async () => {
1809
+ const t = tenantId.parse(ulid());
1810
+ const s = scopeId.parse(ulid());
1811
+ await host.admin.createTenant(staff, { id: t, slug: `del-${t.toLowerCase()}`, name: 'Del' });
1812
+ await host.provisionScope(staff, { tenantId: t, scopeId: s, jurisdiction: 'eu' });
1813
+ await host.admin.activateScope(staff, t, s);
1814
+ await expect(host.getScope(alice, t, s)).resolves.toBeDefined();
1815
+ // Entering `deleting` stamps deletingAt and fails the scope closed (like suspend).
1816
+ await host.admin.setTenantStatus(staff, t, 'deleting');
1817
+ const deleting = (await host.admin.getTenant(staff, t));
1818
+ expect(deleting.status).toBe('deleting');
1819
+ expect(deleting.deletingAt).not.toBeNull();
1820
+ await expect(host.getScope(alice, t, s)).rejects.toThrow(/not active/);
1821
+ // Un-delete (→ active) is a full restore and clears deletingAt.
1822
+ await host.admin.setTenantStatus(staff, t, 'active');
1823
+ const restored = (await host.admin.getTenant(staff, t));
1824
+ expect(restored.status).toBe('active');
1825
+ expect(restored.deletingAt).toBeNull();
1826
+ await expect(host.getScope(alice, t, s)).resolves.toBeDefined();
1827
+ });
1828
+ it('reapTenant clears PII/config rows, keeps the tombstone + admin log, fails closed (§4.8)', async () => {
1829
+ const t = tenantId.parse(ulid());
1830
+ const s = scopeId.parse(ulid());
1831
+ const org = orgId.parse(ulid());
1832
+ const person = principalId.parse(ulid());
1833
+ await host.admin.createTenant(staff, { id: t, slug: `reap-${t.toLowerCase()}`, name: 'Reap Co' });
1834
+ await host.provisionScope(staff, { tenantId: t, scopeId: s, jurisdiction: 'eu' });
1835
+ await host.admin.activateScope(staff, t, s);
1836
+ // Seed the directory-side rows a reap must clear.
1837
+ await host.admin.defineRole(staff, t, { key: 'tech', permissions: [permissionKey.parse('thing:read')], source: 'vertical' });
1838
+ await host.admin.grantEntitlement(staff, t, 'workorder');
1839
+ await host.admin.createOrg(staff, { id: org, tenantId: t, slug: 'acme', name: 'Acme' });
1840
+ await host.admin.registerIdentityPool(staff, { provider: `oidc:reap-${t.toLowerCase()}`, topology: 'tenant-bound', tenantId: t });
1841
+ await host.admin.linkIdentity(staff, { provider: `oidc:reap-${t.toLowerCase()}`, externalId: 'x1', principal: person, tenantId: t });
1842
+ expect(await host.admin.listEntitlements(staff, t)).toHaveLength(1);
1843
+ expect((await host.admin.listOrgs(staff, t)).filter((o) => o.id === org)).toHaveLength(1);
1844
+ expect((await host.admin.resolveIdentity(t, `oidc:reap-${t.toLowerCase()}`, 'x1'))?.principal).toBe(person);
1845
+ // Reap only follows the reversible delete state.
1846
+ await expect(host.admin.reapTenant(staff, t)).rejects.toThrow(/not deleting/);
1847
+ // The caller reaps the scope first (the sweep/route orchestrates this above the
1848
+ // kernel); reapTenant itself is directory-side only.
1849
+ await host.admin.setTenantStatus(staff, t, 'deleting');
1850
+ await host.admin.archiveScope(staff, t, s);
1851
+ await host.admin.reapScope(staff, t, s);
1852
+ await host.admin.reapTenant(staff, t);
1853
+ // The tenant row SURVIVES as a `reaped` tombstone…
1854
+ const tomb = (await host.admin.getTenant(staff, t));
1855
+ expect(tomb.status).toBe('reaped');
1856
+ expect(tomb.slug).toBe(`reap-${t.toLowerCase()}`); // slug stays burned
1857
+ // …its scope fails closed like a missing one…
1858
+ await expect(host.getScope(alice, t, s)).rejects.toThrow(/not active|unknown scope/);
1859
+ // …and the PII/config rows are gone.
1860
+ expect(await host.admin.listEntitlements(staff, t)).toHaveLength(0);
1861
+ expect((await host.admin.listOrgs(staff, t)).filter((o) => o.id === org)).toHaveLength(0);
1862
+ expect(await host.admin.resolveIdentity(t, `oidc:reap-${t.toLowerCase()}`, 'x1')).toBeUndefined();
1863
+ // The admin log is KEPT WHOLE — the witness outlives the tenant — and records the reap.
1864
+ const log = await host.admin.auditLog(staff, { tenantId: t });
1865
+ expect(log.some((r) => r.action === 'createTenant')).toBe(true);
1866
+ const reapEntry = log.find((r) => r.action === 'reapTenant');
1867
+ expect(reapEntry.actor).toBe(staff);
1868
+ expect(reapEntry.before.status).toBe('deleting');
1869
+ expect(reapEntry.after.status).toBe('reaped');
1870
+ // Terminal: `reaped` is unreachable via setTenantStatus and reapTenant refuses it.
1871
+ await expect(host.admin.setTenantStatus(staff, t, 'reaped')).rejects.toThrow(/cannot be set to 'reaped'/);
1872
+ await expect(host.admin.reapTenant(staff, t)).rejects.toThrow(/not deleting/);
1873
+ });
1874
+ it('rejects reapTenant on an unknown tenant', async () => {
1875
+ await expect(host.admin.reapTenant(staff, tenantId.parse(ulid()))).rejects.toThrow(/unknown tenant/);
1876
+ });
1726
1877
  it('rejects a lifecycle transition on a scope not under the named tenant', async () => {
1727
1878
  await expect(host.admin.suspendScope(staff, t1, s3)).rejects.toThrow(/unknown scope for tenant/);
1728
1879
  });
@@ -1738,11 +1889,84 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1738
1889
  // Granting the flag loads the module for this tenant.
1739
1890
  await host.admin.grantEntitlement(staff, t4, 'billed');
1740
1891
  await expect(stub.invoke('billed/act')).resolves.toBe('ran');
1741
- expect(await host.admin.listEntitlements(staff, t4)).toContain('billed');
1892
+ const held = await host.admin.listEntitlements(staff, t4);
1893
+ expect(held.map((e) => e.entitlementKey)).toContain('billed');
1894
+ // A bare grant is #33's null plan: a perpetual boolean flag, stamped.
1895
+ const billed = held.find((e) => e.entitlementKey === 'billed');
1896
+ expect(billed.expiresAt).toBeNull();
1897
+ expect(billed.quota).toBeNull();
1898
+ expect(billed.plan).toBeNull();
1899
+ expect(billed.grantedBy).toBe(staff);
1900
+ expect(billed.grantedAt).not.toBeNull();
1742
1901
  // Revoking it takes the operation away again — as if never registered.
1743
1902
  await host.admin.revokeEntitlement(staff, t4, 'billed');
1744
1903
  await expect(stub.invoke('billed/act')).rejects.toThrow(/not entitled/);
1745
- expect(await host.admin.listEntitlements(staff, t4)).not.toContain('billed');
1904
+ expect((await host.admin.listEntitlements(staff, t4)).map((e) => e.entitlementKey)).not.toContain('billed');
1905
+ });
1906
+ it('fails closed on an expired grant, which stays listed for renewal (#33)', async () => {
1907
+ const stub = await host.getScope(alice, t4, s4);
1908
+ const past = new Date(Date.now() - 60_000).toISOString();
1909
+ const future = new Date(Date.now() + 3_600_000).toISOString();
1910
+ // A grant that has lapsed gates exactly like a revoke…
1911
+ await host.admin.grantEntitlement(staff, t4, 'billed', { expiresAt: past });
1912
+ await expect(stub.invoke('billed/act')).rejects.toThrow(/not entitled/);
1913
+ // …but the row is NOT gone: a lapsed trial must look lapsed, not never-granted.
1914
+ const lapsed = (await host.admin.listEntitlements(staff, t4)).find((e) => e.entitlementKey === 'billed');
1915
+ expect(lapsed?.expiresAt).toBe(past);
1916
+ // Renewal is just a re-grant with a live expiry.
1917
+ await host.admin.grantEntitlement(staff, t4, 'billed', { expiresAt: future });
1918
+ await expect(stub.invoke('billed/act')).resolves.toBe('ran');
1919
+ await host.admin.revokeEntitlement(staff, t4, 'billed');
1920
+ });
1921
+ it('round-trips plan fields, preserves them on a bare re-grant, clears on null (#33)', async () => {
1922
+ await host.admin.grantEntitlement(staff, t4, 'billed', {
1923
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
1924
+ quota: 500,
1925
+ plan: 'pro',
1926
+ });
1927
+ const read = async () => (await host.admin.listEntitlements(staff, t4)).find((e) => e.entitlementKey === 'billed');
1928
+ let g = await read();
1929
+ expect(g.quota).toBe(500);
1930
+ expect(g.plan).toBe('pro');
1931
+ expect(g.expiresAt).not.toBeNull();
1932
+ // A bare re-grant (the idempotent provisioning path) must NOT erase the
1933
+ // plan — omitted preserves; only explicit null clears.
1934
+ await host.admin.grantEntitlement(staff, t4, 'billed');
1935
+ g = await read();
1936
+ expect(g.quota).toBe(500);
1937
+ expect(g.plan).toBe('pro');
1938
+ expect(g.expiresAt).not.toBeNull();
1939
+ await host.admin.grantEntitlement(staff, t4, 'billed', { expiresAt: null, quota: null, plan: null });
1940
+ g = await read();
1941
+ expect(g.expiresAt).toBeNull();
1942
+ expect(g.quota).toBeNull();
1943
+ expect(g.plan).toBeNull();
1944
+ await host.admin.revokeEntitlement(staff, t4, 'billed');
1945
+ });
1946
+ it('exposes the tenant grant to an operation via ctx.entitlement, dropping expired keys (#304)', async () => {
1947
+ const stub = await host.getScope(alice, t4, s4);
1948
+ const future = new Date(Date.now() + 3_600_000).toISOString();
1949
+ // Hold 'billed' so the (billed-gated) read operation itself resolves, granted with a full plan.
1950
+ await host.admin.grantEntitlement(staff, t4, 'billed', { expiresAt: future, quota: 500, plan: 'pro' });
1951
+ // The held grant reads back as a view — the plan/quota/expiry a vertical gates features on.
1952
+ expect(await stub.invoke('billed/read-entitlement', 'billed')).toEqual({
1953
+ key: 'billed',
1954
+ plan: 'pro',
1955
+ quota: 500,
1956
+ expiresAt: future,
1957
+ });
1958
+ // ctx.entitlements() lists every currently-held grant as a view.
1959
+ const all = (await stub.invoke('billed/list-entitlements')) ?? [];
1960
+ expect(all.map((e) => e.key)).toContain('billed');
1961
+ // A key the tenant does not hold reads as null — the vertical branches on it, no throw.
1962
+ expect(await stub.invoke('billed/read-entitlement', 'never-granted')).toBeNull();
1963
+ // An expired grant is dropped at read, exactly as it is dropped at the gate (#33).
1964
+ await host.admin.grantEntitlement(staff, t4, 'aux-sku', {
1965
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
1966
+ });
1967
+ expect(await stub.invoke('billed/read-entitlement', 'aux-sku')).toBeNull();
1968
+ await host.admin.revokeEntitlement(staff, t4, 'billed');
1969
+ await host.admin.revokeEntitlement(staff, t4, 'aux-sku');
1746
1970
  });
1747
1971
  it('audits grant/revoke idempotently and records the SKU flag', async () => {
1748
1972
  // Re-grant twice: only the first is a real change, so only one row.
@@ -1752,6 +1976,19 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1752
1976
  r.after.entitlementKey === 'audited-sku');
1753
1977
  expect(grants).toHaveLength(1);
1754
1978
  expect(grants[0].actor).toBe(staff);
1979
+ // A re-grant that CHANGES the plan is a renewal — audited, with the old
1980
+ // plan in `before` (#33). The audit row is the grant's history.
1981
+ const until = new Date(Date.now() + 3_600_000).toISOString();
1982
+ await host.admin.grantEntitlement(staff, t4, 'audited-sku', { expiresAt: until, plan: 'pro' });
1983
+ const renewals = (await host.admin.auditLog(staff, { tenantId: t4 })).filter((r) => r.action === 'grantEntitlement' &&
1984
+ r.after.entitlementKey === 'audited-sku');
1985
+ expect(renewals).toHaveLength(2);
1986
+ const renewal = renewals[renewals.length - 1];
1987
+ expect(renewal.before.plan).toBeNull();
1988
+ expect(renewal.after).toMatchObject({
1989
+ plan: 'pro',
1990
+ expiresAt: until,
1991
+ });
1755
1992
  await host.admin.revokeEntitlement(staff, t4, 'audited-sku');
1756
1993
  const revokes = (await host.admin.auditLog(staff, { tenantId: t4 })).filter((r) => r.action === 'revokeEntitlement');
1757
1994
  expect(revokes.length).toBeGreaterThanOrEqual(1);