@noy-db/test-format-conformance 0.7.0-pre.1 → 0.7.0-pre.10
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/index.d.ts +38 -12
- package/dist/index.js +58 -34
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Vault, ExportFormat } from '@noy-db/hub';
|
|
|
2
2
|
|
|
3
3
|
/** One plaintext-producing entry point, named as a consumer would call it. */
|
|
4
4
|
interface FormatEntryPoint {
|
|
5
|
-
/**
|
|
5
|
+
/** Shown in the test title, e.g. `'toString'` or `'vault.export'`. */
|
|
6
6
|
readonly name: string;
|
|
7
7
|
/** Call it against the supplied vault. Arguments are the fixture's business. */
|
|
8
8
|
run(vault: Vault): Promise<unknown>;
|
|
@@ -25,34 +25,60 @@ interface FormatFixture {
|
|
|
25
25
|
readonly format?: ExportFormat;
|
|
26
26
|
/**
|
|
27
27
|
* A REAL vault with at least one record. Built fresh per case, so an entry
|
|
28
|
-
* point that mutates it cannot leak into the next assertion
|
|
28
|
+
* point that mutates it cannot leak into the next assertion — and because
|
|
29
|
+
* the kit PATCHES the instance it is handed, reuse would leak the patch.
|
|
30
|
+
*
|
|
31
|
+
* For a format using the inverted shape (`vault.export(...)`), the vault
|
|
32
|
+
* must be created with `formatsStrategy: withFormats()` — without it the
|
|
33
|
+
* CAN-export guard fails with `FormatsNotEnabledError` before proving
|
|
34
|
+
* anything about the fixture's grants.
|
|
29
35
|
*/
|
|
30
36
|
vault(): Promise<Vault>;
|
|
31
37
|
/**
|
|
32
|
-
* EVERY entry point
|
|
38
|
+
* EVERY plaintext-producing export entry point — not a representative one.
|
|
33
39
|
* A format with four exports and one listed here reports a green suite for
|
|
34
40
|
* the three nobody checked.
|
|
35
41
|
*/
|
|
36
42
|
readonly exports: ReadonlyArray<FormatEntryPoint>;
|
|
43
|
+
/**
|
|
44
|
+
* Import entry points (`vault.import(...)`, legacy `fromString`), gated by
|
|
45
|
+
* `assertCanImport`. Optional because not every format decodes — but a
|
|
46
|
+
* format that ships a `decode` and declares no imports here is reporting a
|
|
47
|
+
* green suite for a gate nobody checked, and the suite says so out loud.
|
|
48
|
+
*
|
|
49
|
+
* The fixture's vault must hold an `importCapability` grant for the format,
|
|
50
|
+
* or the denial case is unfalsifiable: the refusal arrives from the missing
|
|
51
|
+
* grant rather than from the kit's denial, and nothing distinguishes that
|
|
52
|
+
* from a working gate.
|
|
53
|
+
*/
|
|
54
|
+
readonly imports?: ReadonlyArray<FormatEntryPoint>;
|
|
37
55
|
/**
|
|
38
56
|
* The on-disk write path, if the package has one. It must refuse without
|
|
39
57
|
* `acknowledgeRisks: true`; pass a call that OMITS the flag.
|
|
40
58
|
*
|
|
41
|
-
* The vault this receives is the fixture's own — NOT
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
59
|
+
* The vault this receives is the fixture's own — NOT a denying one — and it
|
|
60
|
+
* must be export-CAPABLE. A vault that would refuse the export anyway makes
|
|
61
|
+
* the case unfalsifiable: the refusal arrives from the gate upstream and the
|
|
62
|
+
* acknowledgement is never reached. That is not hypothetical; it is what the
|
|
63
|
+
* first version of this kit did, and deleting the acknowledgement guard from
|
|
64
|
+
* as-csv left the suite green.
|
|
47
65
|
*/
|
|
48
66
|
writeWithoutAcknowledgement?: (vault: Vault, path: string) => Promise<unknown>;
|
|
49
67
|
}
|
|
50
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Thrown by the kit's denial patch so a refusal is ATTRIBUTABLE to the gate.
|
|
70
|
+
*
|
|
71
|
+
* The denial tests match on this class, not on "it threw". A bare
|
|
72
|
+
* `rejects.toThrow()` passes on any error — a miswired fixture raising
|
|
73
|
+
* `TypeError`, a vault missing `withFormats()` — which is exactly the state a
|
|
74
|
+
* brand-new fixture is most likely to be in. The first version of this kit
|
|
75
|
+
* defined this class for that purpose and then never matched on it.
|
|
76
|
+
*/
|
|
51
77
|
declare class ExportDeniedByConformanceKit extends Error {
|
|
52
|
-
constructor(tier: string, format?: string);
|
|
78
|
+
constructor(gate: 'export' | 'import', tier: string, format?: string);
|
|
53
79
|
}
|
|
54
80
|
/**
|
|
55
|
-
* Run the shared `as-*`
|
|
81
|
+
* Run the shared `as-*` gate contract against one format.
|
|
56
82
|
*
|
|
57
83
|
* @param name - shown in the suite title, e.g. `'as-csv'`.
|
|
58
84
|
*/
|
package/dist/index.js
CHANGED
|
@@ -1,31 +1,25 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
3
|
var ExportDeniedByConformanceKit = class extends Error {
|
|
4
|
-
constructor(tier, format) {
|
|
5
|
-
super(`conformance:
|
|
4
|
+
constructor(gate, tier, format) {
|
|
5
|
+
super(`conformance: assertCan${gate === "export" ? "Export" : "Import"} denied '${tier}'${format ? ` / '${format}'` : ""}`);
|
|
6
6
|
this.name = "ExportDeniedByConformanceKit";
|
|
7
7
|
}
|
|
8
8
|
};
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return value.apply(target, args);
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
return value;
|
|
27
|
-
}
|
|
28
|
-
});
|
|
9
|
+
function denyGates(vault, tier, format, seen) {
|
|
10
|
+
const v = vault;
|
|
11
|
+
v["assertCanExport"] = () => {
|
|
12
|
+
throw new ExportDeniedByConformanceKit("export", tier, format);
|
|
13
|
+
};
|
|
14
|
+
v["assertCanImport"] = () => {
|
|
15
|
+
throw new ExportDeniedByConformanceKit("import", tier, format);
|
|
16
|
+
};
|
|
17
|
+
const realStream = vault.exportStream.bind(vault);
|
|
18
|
+
v["exportStream"] = (...args) => {
|
|
19
|
+
seen.decryptCalls.push("exportStream");
|
|
20
|
+
return realStream(...args);
|
|
21
|
+
};
|
|
22
|
+
return vault;
|
|
29
23
|
}
|
|
30
24
|
function runFormatConformanceTests(name, fixture) {
|
|
31
25
|
describe(`${name} \u2014 as-* export gate conformance`, () => {
|
|
@@ -39,29 +33,59 @@ function runFormatConformanceTests(name, fixture) {
|
|
|
39
33
|
it("declares at least one export entry point", () => {
|
|
40
34
|
expect(fixture.exports.length).toBeGreaterThan(0);
|
|
41
35
|
});
|
|
42
|
-
it("the fixture vault CAN export \u2014 otherwise every refusal below is free", async () => {
|
|
43
|
-
const vault = await fixture.vault();
|
|
44
|
-
await expect(
|
|
45
|
-
fixture.exports[0].run(vault),
|
|
46
|
-
`${fixture.exports[0].name} failed on an ungated vault \u2014 check the \`format\` tag and the exportCapability grant`
|
|
47
|
-
).resolves.toBeDefined();
|
|
48
|
-
});
|
|
49
36
|
for (const entry of fixture.exports) {
|
|
50
|
-
it(`${entry.name}:
|
|
37
|
+
it(`${entry.name}: SUCCEEDS on an ungated vault \u2014 otherwise its refusal below is free`, async () => {
|
|
38
|
+
const vault = await fixture.vault();
|
|
39
|
+
await expect(
|
|
40
|
+
entry.run(vault),
|
|
41
|
+
`${entry.name} failed on an ungated vault \u2014 check the \`format\` tag, the exportCapability grant, and (for vault.export entries) formatsStrategy: withFormats()`
|
|
42
|
+
).resolves.toSatisfy(() => true);
|
|
43
|
+
});
|
|
44
|
+
it(`${entry.name}: REFUSES when assertCanExport denies \u2014 with the KIT'S error`, async () => {
|
|
51
45
|
const seen = { decryptCalls: [] };
|
|
52
|
-
const vault =
|
|
53
|
-
await expect(entry.run(vault)).rejects.toThrow();
|
|
46
|
+
const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen);
|
|
47
|
+
await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit);
|
|
54
48
|
});
|
|
55
49
|
it(`${entry.name}: refuses BEFORE reading any record`, async () => {
|
|
56
50
|
const seen = { decryptCalls: [] };
|
|
57
|
-
const vault =
|
|
58
|
-
await expect(entry.run(vault)).rejects.toThrow();
|
|
51
|
+
const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen);
|
|
52
|
+
await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit);
|
|
59
53
|
expect(
|
|
60
54
|
seen.decryptCalls,
|
|
61
55
|
`${entry.name} read records before the export gate refused`
|
|
62
56
|
).toEqual([]);
|
|
63
57
|
});
|
|
64
58
|
}
|
|
59
|
+
const importEntries = fixture.imports ?? [];
|
|
60
|
+
const importTitle = importEntries.length ? null : "imports: SKIPPED \u2014 fixture declares none, so the assertCanImport gate is UNVERIFIED here";
|
|
61
|
+
if (importTitle) {
|
|
62
|
+
it(importTitle, () => {
|
|
63
|
+
expect(importEntries).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const entry of importEntries) {
|
|
67
|
+
it(`${entry.name}: SUCCEEDS on an ungated vault \u2014 otherwise its refusal below is free`, async () => {
|
|
68
|
+
const vault = await fixture.vault();
|
|
69
|
+
await expect(
|
|
70
|
+
entry.run(vault),
|
|
71
|
+
`${entry.name} failed on an ungated vault \u2014 check the importCapability grant`
|
|
72
|
+
).resolves.toSatisfy(() => true);
|
|
73
|
+
});
|
|
74
|
+
it(`${entry.name}: REFUSES when assertCanImport denies \u2014 with the KIT'S error`, async () => {
|
|
75
|
+
const seen = { decryptCalls: [] };
|
|
76
|
+
const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen);
|
|
77
|
+
await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit);
|
|
78
|
+
});
|
|
79
|
+
it(`${entry.name}: refuses BEFORE reading any record`, async () => {
|
|
80
|
+
const seen = { decryptCalls: [] };
|
|
81
|
+
const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen);
|
|
82
|
+
await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit);
|
|
83
|
+
expect(
|
|
84
|
+
seen.decryptCalls,
|
|
85
|
+
`${entry.name} read records before the import gate refused`
|
|
86
|
+
).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
65
89
|
const writeTitle = fixture.writeWithoutAcknowledgement ? "write: REFUSES without acknowledgeRisks" : "write: SKIPPED \u2014 fixture declares no acknowledgement case, so the plaintext-on-disk gate is UNVERIFIED here";
|
|
66
90
|
it(writeTitle, async () => {
|
|
67
91
|
const write = fixture.writeWithoutAcknowledgement;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/test-format-conformance** — the `as-*` export gate, published as\n * an executable suite.\n *\n * The `as-*` family is the one place plaintext leaves the vault. Each package\n * calls `vault.assertCanExport('plaintext', <format>)` before producing\n * anything, and that call is the whole security boundary: a projection that\n * skips it hands out decrypted records to a caller the vault would have\n * refused.\n *\n * Nothing enforced it. Nine packages had converged on the same shape by\n * convention — `toString`/`toBytes`, `download`, `write` — and convention is\n * what the next format author reads instead of a contract.\n *\n * ## Gated is not the property. Gated BEFORE decrypting is.\n *\n * A gate called after `exportStream` has already run is a gate that refuses\n * the caller and decrypts anyway. So the suite asserts BOTH:\n *\n * - every export entry point REJECTS when `assertCanExport` throws, and\n * - it rejects having read NOTHING — `exportStream` is never called.\n *\n * The second is the one a delegation refactor breaks silently: move the gate\n * from `toObject` into `download` and every existing test still passes.\n *\n * ## Why a Proxy and not a fake Vault\n *\n * `Vault` is large, and a hand-written double would drift from it — and worse,\n * would only ever exercise the methods whoever wrote the double thought of.\n * The fixture supplies a REAL vault; the kit wraps it, so an entry point that\n * reaches for some other decrypting method is still observed.\n *\n * @packageDocumentation\n */\nimport { describe, it, expect } from 'vitest'\nimport type { Vault, ExportFormat } from '@noy-db/hub'\n\n/** One plaintext-producing entry point, named as a consumer would call it. */\nexport interface FormatEntryPoint {\n /** Exported function name, e.g. `'toString'`. Used in the test title. */\n readonly name: string\n /** Call it against the supplied vault. Arguments are the fixture's business. */\n run(vault: Vault): Promise<unknown>\n}\n\n/** Everything an `as-*` package must supply to be checked against the gate. */\nexport interface FormatFixture {\n /**\n * The TIER the package passes to `assertCanExport`. The `as-*` family is\n * two capability classes, not one — discovered by wiring `as-noydb`, which\n * calls `assertCanExport('bundle')` and never mentions plaintext because it\n * emits an encrypted pod. A kit that assumed one tier would have made that\n * fixture describe itself wrongly while still passing.\n */\n readonly tier: 'plaintext' | 'bundle'\n /**\n * The format tag, e.g. `'csv'`. REQUIRED for the plaintext tier and\n * meaningless for `bundle` — hub itself throws when a plaintext check\n * arrives without one, so the pairing is asserted rather than assumed.\n */\n readonly format?: ExportFormat\n /**\n * A REAL vault with at least one record. Built fresh per case, so an entry\n * point that mutates it cannot leak into the next assertion.\n */\n vault(): Promise<Vault>\n /**\n * EVERY entry point that can produce plaintext — not a representative one.\n * A format with four exports and one listed here reports a green suite for\n * the three nobody checked.\n */\n readonly exports: ReadonlyArray<FormatEntryPoint>\n /**\n * The on-disk write path, if the package has one. It must refuse without\n * `acknowledgeRisks: true`; pass a call that OMITS the flag.\n *\n * The vault this receives is the fixture's own — NOT the denying proxy —\n * and it must be export-CAPABLE. A vault that would refuse the export\n * anyway makes the case unfalsifiable: the refusal arrives from the gate\n * upstream and the acknowledgement is never reached. That is not\n * hypothetical; it is what the first version of this kit did, and deleting\n * the acknowledgement guard from as-csv left the suite green.\n */\n writeWithoutAcknowledgement?: (vault: Vault, path: string) => Promise<unknown>\n}\n\n/** Thrown by the denying proxy so a refusal is attributable to the gate. */\nexport class ExportDeniedByConformanceKit extends Error {\n constructor(tier: string, format?: string) {\n super(`conformance: assertCanExport denied '${tier}'${format ? ` / '${format}'` : ''}`)\n this.name = 'ExportDeniedByConformanceKit'\n }\n}\n\ninterface Observation {\n decryptCalls: string[]\n}\n\n/**\n * Wrap a real vault so `assertCanExport` denies, and every method that could\n * yield plaintext records is recorded.\n *\n * `exportStream` is named explicitly because it is the shared read path; the\n * catch-all records any other function property that gets invoked, so an entry\n * point taking a different route is still visible rather than silently\n * unobserved.\n */\nfunction denyingVault(real: Vault, tier: string, format: string | undefined, seen: Observation): Vault {\n return new Proxy(real, {\n get(target, prop, receiver) {\n if (prop === 'assertCanExport') {\n return () => {\n throw new ExportDeniedByConformanceKit(tier, format)\n }\n }\n const value = Reflect.get(target, prop, receiver) as unknown\n if (typeof value === 'function' && typeof prop === 'string') {\n return (...args: unknown[]) => {\n if (prop === 'exportStream' || prop === 'export' || prop === 'snapshot') {\n seen.decryptCalls.push(prop)\n }\n return (value as (...a: unknown[]) => unknown).apply(target, args)\n }\n }\n return value\n },\n }) as Vault\n}\n\n/**\n * Run the shared `as-*` export-gate contract against one format.\n *\n * @param name - shown in the suite title, e.g. `'as-csv'`.\n */\nexport function runFormatConformanceTests(name: string, fixture: FormatFixture): void {\n describe(`${name} — as-* export gate conformance`, () => {\n it('declares a tier, and a format iff the tier needs one', () => {\n // Hub throws on `assertCanExport('plaintext')` with no format, so a\n // fixture in that state describes a call the package cannot be making.\n if (fixture.tier === 'plaintext') {\n expect(fixture.format, 'the plaintext tier requires a format').toBeTruthy()\n } else {\n expect(fixture.format, `the '${fixture.tier}' tier takes no format`).toBeUndefined()\n }\n })\n\n it('declares at least one export entry point', () => {\n // A fixture with an empty list would pass every case below without\n // running anything — a live suite iterating an empty array.\n expect(fixture.exports.length).toBeGreaterThan(0)\n })\n\n it('the fixture vault CAN export — otherwise every refusal below is free', async () => {\n // The whole suite tests refusals, and a refusal is only evidence when\n // the same call would otherwise SUCCEED. A fixture whose `format` tag\n // does not match what the package passes to `assertCanExport` — or\n // which forgets the `exportCapability` grant — makes every case below\n // pass by refusing for the wrong reason, and nothing in the output\n // distinguishes that from a working gate.\n const vault = await fixture.vault()\n await expect(\n fixture.exports[0]!.run(vault),\n `${fixture.exports[0]!.name} failed on an ungated vault — check the \\`format\\` tag and the exportCapability grant`,\n ).resolves.toBeDefined()\n })\n\n for (const entry of fixture.exports) {\n it(`${entry.name}: REFUSES when assertCanExport denies`, async () => {\n const seen: Observation = { decryptCalls: [] }\n const vault = denyingVault(await fixture.vault(), fixture.tier, fixture.format, seen)\n await expect(entry.run(vault)).rejects.toThrow()\n })\n\n it(`${entry.name}: refuses BEFORE reading any record`, async () => {\n const seen: Observation = { decryptCalls: [] }\n const vault = denyingVault(await fixture.vault(), fixture.tier, fixture.format, seen)\n await expect(entry.run(vault)).rejects.toThrow()\n // The property that a delegation refactor breaks silently: a gate\n // moved downstream still refuses the caller, having already decrypted.\n expect(\n seen.decryptCalls,\n `${entry.name} read records before the export gate refused`,\n ).toEqual([])\n })\n }\n\n const writeTitle = fixture.writeWithoutAcknowledgement\n ? 'write: REFUSES without acknowledgeRisks'\n : 'write: SKIPPED — fixture declares no acknowledgement case, so the plaintext-on-disk gate is UNVERIFIED here'\n\n it(writeTitle, async () => {\n const write = fixture.writeWithoutAcknowledgement\n if (!write) {\n // Passes loudly. Omitting the case would make an unchecked security\n // gate indistinguishable from a checked one in the output.\n expect(write).toBeUndefined()\n return\n }\n const vault = await fixture.vault()\n // Matched on the MESSAGE, not merely on \"it threw\". `rejects.toThrow()`\n // alone passes when the export gate refuses first — which is exactly\n // what happened here before this line existed, and it made the case\n // unable to fail. The flag name is the one string every such message\n // contains by construction.\n await expect(write(vault, '/tmp/conformance-should-not-exist')).rejects.toThrow(\n /acknowledgeRisks/i,\n )\n })\n })\n}\n"],"mappings":";AAkCA,SAAS,UAAU,IAAI,cAAc;AAqD9B,IAAM,+BAAN,cAA2C,MAAM;AAAA,EACtD,YAAY,MAAc,QAAiB;AACzC,UAAM,wCAAwC,IAAI,IAAI,SAAS,OAAO,MAAM,MAAM,EAAE,EAAE;AACtF,SAAK,OAAO;AAAA,EACd;AACF;AAeA,SAAS,aAAa,MAAa,MAAc,QAA4B,MAA0B;AACrG,SAAO,IAAI,MAAM,MAAM;AAAA,IACrB,IAAI,QAAQ,MAAM,UAAU;AAC1B,UAAI,SAAS,mBAAmB;AAC9B,eAAO,MAAM;AACX,gBAAM,IAAI,6BAA6B,MAAM,MAAM;AAAA,QACrD;AAAA,MACF;AACA,YAAM,QAAQ,QAAQ,IAAI,QAAQ,MAAM,QAAQ;AAChD,UAAI,OAAO,UAAU,cAAc,OAAO,SAAS,UAAU;AAC3D,eAAO,IAAI,SAAoB;AAC7B,cAAI,SAAS,kBAAkB,SAAS,YAAY,SAAS,YAAY;AACvE,iBAAK,aAAa,KAAK,IAAI;AAAA,UAC7B;AACA,iBAAQ,MAAuC,MAAM,QAAQ,IAAI;AAAA,QACnE;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAAA,EACF,CAAC;AACH;AAOO,SAAS,0BAA0B,MAAc,SAA8B;AACpF,WAAS,GAAG,IAAI,wCAAmC,MAAM;AACvD,OAAG,wDAAwD,MAAM;AAG/D,UAAI,QAAQ,SAAS,aAAa;AAChC,eAAO,QAAQ,QAAQ,sCAAsC,EAAE,WAAW;AAAA,MAC5E,OAAO;AACL,eAAO,QAAQ,QAAQ,QAAQ,QAAQ,IAAI,wBAAwB,EAAE,cAAc;AAAA,MACrF;AAAA,IACF,CAAC;AAED,OAAG,4CAA4C,MAAM;AAGnD,aAAO,QAAQ,QAAQ,MAAM,EAAE,gBAAgB,CAAC;AAAA,IAClD,CAAC;AAED,OAAG,6EAAwE,YAAY;AAOrF,YAAM,QAAQ,MAAM,QAAQ,MAAM;AAClC,YAAM;AAAA,QACJ,QAAQ,QAAQ,CAAC,EAAG,IAAI,KAAK;AAAA,QAC7B,GAAG,QAAQ,QAAQ,CAAC,EAAG,IAAI;AAAA,MAC7B,EAAE,SAAS,YAAY;AAAA,IACzB,CAAC;AAED,eAAW,SAAS,QAAQ,SAAS;AACnC,SAAG,GAAG,MAAM,IAAI,yCAAyC,YAAY;AACnE,cAAM,OAAoB,EAAE,cAAc,CAAC,EAAE;AAC7C,cAAM,QAAQ,aAAa,MAAM,QAAQ,MAAM,GAAG,QAAQ,MAAM,QAAQ,QAAQ,IAAI;AACpF,cAAM,OAAO,MAAM,IAAI,KAAK,CAAC,EAAE,QAAQ,QAAQ;AAAA,MACjD,CAAC;AAED,SAAG,GAAG,MAAM,IAAI,uCAAuC,YAAY;AACjE,cAAM,OAAoB,EAAE,cAAc,CAAC,EAAE;AAC7C,cAAM,QAAQ,aAAa,MAAM,QAAQ,MAAM,GAAG,QAAQ,MAAM,QAAQ,QAAQ,IAAI;AACpF,cAAM,OAAO,MAAM,IAAI,KAAK,CAAC,EAAE,QAAQ,QAAQ;AAG/C;AAAA,UACE,KAAK;AAAA,UACL,GAAG,MAAM,IAAI;AAAA,QACf,EAAE,QAAQ,CAAC,CAAC;AAAA,MACd,CAAC;AAAA,IACH;AAEA,UAAM,aAAa,QAAQ,8BACvB,4CACA;AAEJ,OAAG,YAAY,YAAY;AACzB,YAAM,QAAQ,QAAQ;AACtB,UAAI,CAAC,OAAO;AAGV,eAAO,KAAK,EAAE,cAAc;AAC5B;AAAA,MACF;AACA,YAAM,QAAQ,MAAM,QAAQ,MAAM;AAMlC,YAAM,OAAO,MAAM,OAAO,mCAAmC,CAAC,EAAE,QAAQ;AAAA,QACtE;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/test-format-conformance** — the `as-*` export/import gate,\n * published as an executable suite.\n *\n * The `as-*` family is the one place plaintext leaves the vault. Every export\n * is gated by `vault.assertCanExport(tier, format)` before producing anything,\n * and that call is the whole security boundary: a projection that skips it\n * hands out decrypted records to a caller the vault would have refused. The\n * import side is `vault.assertCanImport`, gating what may be planned INTO a\n * vault.\n *\n * ## Two entry-point shapes, one contract\n *\n * The 0.7 line inverted four formats: the entry point moved from a function\n * taking the vault as an ARGUMENT (`toString(vault, opts)`) to a METHOD ON the\n * vault (`vault.export(asCsv(), {})`). Both shapes carry the same obligation,\n * and this kit checks both with one mechanism — see the next section, because\n * getting that mechanism wrong is precisely how this kit went blind once.\n *\n * ## Gated is not the property. Gated BEFORE decrypting is.\n *\n * A gate called after `exportStream` has already run is a gate that refuses\n * the caller and decrypts anyway. So the suite asserts BOTH:\n *\n * - every entry point REJECTS when the gate denies — and rejects with THIS\n * KIT'S OWN ERROR, so the refusal is attributable to the gate rather than\n * to a miswired fixture throwing something else; and\n * - it rejects having read NOTHING — `exportStream` is never called.\n *\n * The second is the one a delegation refactor breaks silently: move the gate\n * downstream and every existing test still passes.\n *\n * ## Why the kit PATCHES THE INSTANCE, and no longer proxies it (#1209)\n *\n * The first version wrapped the vault in a `Proxy` whose `get` trap replaced\n * `assertCanExport`, forwarding calls with `value.apply(target, args)`. That\n * works when the entry point takes the vault as an argument — the package\n * calls `proxy.assertCanExport(...)` and the trap fires. It CANNOT work for a\n * method on the vault: `vault.export` runs with `this` bound to the real\n * object (`apply(receiver, …)` is not an option — `Vault` has private fields,\n * and a Proxy receiver breaks private-field access), so the gate it consults\n * is the unproxied one and the denial is silently bypassed. Both assertions\n * passed vacuously; nothing turned red.\n *\n * Patching own properties onto the REAL instance intercepts both shapes,\n * because property lookup happens at call time and an own property shadows the\n * prototype method — including for hub's own INTERNAL delegation\n * (`exportJSON()` calls `this.exportStream(...)`, which the Proxy never saw\n * and the patch does). Private fields keep working because it IS the real\n * object. The patch mutates the fixture's vault, which is why `vault()` must\n * build a fresh one per case — a requirement the fixture already carries.\n *\n * If hub ever routes its gate around `vault.assertCanExport` (say, by inlining\n * the capability check), this mechanism fails LOUD — the ungated call\n * succeeds, the denial test goes red — not silent. That is the acceptable\n * failure direction.\n *\n * ## What is observed, and what deliberately is not\n *\n * `exportStream` is the decrypting PRIMITIVE, and the only method recorded.\n * `vault.export` / `vault.import` are NOT recorded: under the inverted shape\n * they are the entry points themselves (and `download`/`write` call\n * `vault.export` internally), so recording them would fail every correct\n * inverted format spuriously. The first version also recorded a `snapshot`\n * method that `Vault` does not have — a guessed identifier, which is a query\n * that cannot falsify. The list is now exactly the primitives that exist.\n *\n * @packageDocumentation\n */\nimport { describe, it, expect } from 'vitest'\nimport type { Vault, ExportFormat } from '@noy-db/hub'\n\n/** One plaintext-producing entry point, named as a consumer would call it. */\nexport interface FormatEntryPoint {\n /** Shown in the test title, e.g. `'toString'` or `'vault.export'`. */\n readonly name: string\n /** Call it against the supplied vault. Arguments are the fixture's business. */\n run(vault: Vault): Promise<unknown>\n}\n\n/** Everything an `as-*` package must supply to be checked against the gate. */\nexport interface FormatFixture {\n /**\n * The TIER the package passes to `assertCanExport`. The `as-*` family is\n * two capability classes, not one — discovered by wiring `as-noydb`, which\n * calls `assertCanExport('bundle')` and never mentions plaintext because it\n * emits an encrypted pod. A kit that assumed one tier would have made that\n * fixture describe itself wrongly while still passing.\n */\n readonly tier: 'plaintext' | 'bundle'\n /**\n * The format tag, e.g. `'csv'`. REQUIRED for the plaintext tier and\n * meaningless for `bundle` — hub itself throws when a plaintext check\n * arrives without one, so the pairing is asserted rather than assumed.\n */\n readonly format?: ExportFormat\n /**\n * A REAL vault with at least one record. Built fresh per case, so an entry\n * point that mutates it cannot leak into the next assertion — and because\n * the kit PATCHES the instance it is handed, reuse would leak the patch.\n *\n * For a format using the inverted shape (`vault.export(...)`), the vault\n * must be created with `formatsStrategy: withFormats()` — without it the\n * CAN-export guard fails with `FormatsNotEnabledError` before proving\n * anything about the fixture's grants.\n */\n vault(): Promise<Vault>\n /**\n * EVERY plaintext-producing export entry point — not a representative one.\n * A format with four exports and one listed here reports a green suite for\n * the three nobody checked.\n */\n readonly exports: ReadonlyArray<FormatEntryPoint>\n /**\n * Import entry points (`vault.import(...)`, legacy `fromString`), gated by\n * `assertCanImport`. Optional because not every format decodes — but a\n * format that ships a `decode` and declares no imports here is reporting a\n * green suite for a gate nobody checked, and the suite says so out loud.\n *\n * The fixture's vault must hold an `importCapability` grant for the format,\n * or the denial case is unfalsifiable: the refusal arrives from the missing\n * grant rather than from the kit's denial, and nothing distinguishes that\n * from a working gate.\n */\n readonly imports?: ReadonlyArray<FormatEntryPoint>\n /**\n * The on-disk write path, if the package has one. It must refuse without\n * `acknowledgeRisks: true`; pass a call that OMITS the flag.\n *\n * The vault this receives is the fixture's own — NOT a denying one — and it\n * must be export-CAPABLE. A vault that would refuse the export anyway makes\n * the case unfalsifiable: the refusal arrives from the gate upstream and the\n * acknowledgement is never reached. That is not hypothetical; it is what the\n * first version of this kit did, and deleting the acknowledgement guard from\n * as-csv left the suite green.\n */\n writeWithoutAcknowledgement?: (vault: Vault, path: string) => Promise<unknown>\n}\n\n/**\n * Thrown by the kit's denial patch so a refusal is ATTRIBUTABLE to the gate.\n *\n * The denial tests match on this class, not on \"it threw\". A bare\n * `rejects.toThrow()` passes on any error — a miswired fixture raising\n * `TypeError`, a vault missing `withFormats()` — which is exactly the state a\n * brand-new fixture is most likely to be in. The first version of this kit\n * defined this class for that purpose and then never matched on it.\n */\nexport class ExportDeniedByConformanceKit extends Error {\n constructor(gate: 'export' | 'import', tier: string, format?: string) {\n super(`conformance: assertCan${gate === 'export' ? 'Export' : 'Import'} denied '${tier}'${format ? ` / '${format}'` : ''}`)\n this.name = 'ExportDeniedByConformanceKit'\n }\n}\n\ninterface Observation {\n decryptCalls: string[]\n}\n\n/**\n * Patch a REAL vault in place: both gates deny with the kit's own error, and\n * the decrypting primitive is recorded. Returns the same instance.\n *\n * Own-property assignment shadows the prototype methods, so the patch fires\n * for the argument shape (`toString(vault)` → `vault.assertCanExport(...)`),\n * the inverted shape (`vault.export(...)` → `contextFor(this)` → property\n * lookup at call time), and hub's internal delegation (`exportJSON()` →\n * `this.exportStream(...)`).\n */\nfunction denyGates(vault: Vault, tier: string, format: string | undefined, seen: Observation): Vault {\n const v = vault as unknown as Record<string, unknown>\n v['assertCanExport'] = () => {\n throw new ExportDeniedByConformanceKit('export', tier, format)\n }\n v['assertCanImport'] = () => {\n throw new ExportDeniedByConformanceKit('import', tier, format)\n }\n const realStream = (vault.exportStream as (...a: unknown[]) => unknown).bind(vault)\n v['exportStream'] = (...args: unknown[]) => {\n seen.decryptCalls.push('exportStream')\n return realStream(...args)\n }\n return vault\n}\n\n/**\n * Run the shared `as-*` gate contract against one format.\n *\n * @param name - shown in the suite title, e.g. `'as-csv'`.\n */\nexport function runFormatConformanceTests(name: string, fixture: FormatFixture): void {\n describe(`${name} — as-* export gate conformance`, () => {\n it('declares a tier, and a format iff the tier needs one', () => {\n // Hub throws on `assertCanExport('plaintext')` with no format, so a\n // fixture in that state describes a call the package cannot be making.\n if (fixture.tier === 'plaintext') {\n expect(fixture.format, 'the plaintext tier requires a format').toBeTruthy()\n } else {\n expect(fixture.format, `the '${fixture.tier}' tier takes no format`).toBeUndefined()\n }\n })\n\n it('declares at least one export entry point', () => {\n // A fixture with an empty list would pass every case below without\n // running anything — a live suite iterating an empty array.\n expect(fixture.exports.length).toBeGreaterThan(0)\n })\n\n for (const entry of fixture.exports) {\n it(`${entry.name}: SUCCEEDS on an ungated vault — otherwise its refusal below is free`, async () => {\n // Per ENTRY, not only exports[0]: a refusal is only evidence when the\n // same call would otherwise succeed, and each entry point can be\n // miswired independently. A fixture whose `format` tag does not match\n // what the package passes — or which forgets the exportCapability\n // grant, or omits `formatsStrategy: withFormats()` on an inverted\n // vault — makes the denial pass by refusing for the wrong reason.\n const vault = await fixture.vault()\n // `toSatisfy(() => true)`, not `toBeDefined()`: `download`/`write`\n // return Promise<void>, and their resolved value is legitimately\n // undefined. The assertion is \"it RESOLVES\" — the guard is about the\n // call not refusing, not about what it returns. (Found the moment this\n // guard went per-entry; the old exports[0]-only guard happened to\n // always land on a value-returning entry.)\n await expect(\n entry.run(vault),\n `${entry.name} failed on an ungated vault — check the \\`format\\` tag, the exportCapability grant, and (for vault.export entries) formatsStrategy: withFormats()`,\n ).resolves.toSatisfy(() => true)\n })\n\n it(`${entry.name}: REFUSES when assertCanExport denies — with the KIT'S error`, async () => {\n const seen: Observation = { decryptCalls: [] }\n const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen)\n // Matched on the class: a bare toThrow() passes on ANY error, which\n // makes a miswired fixture indistinguishable from a working gate.\n await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit)\n })\n\n it(`${entry.name}: refuses BEFORE reading any record`, async () => {\n const seen: Observation = { decryptCalls: [] }\n const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen)\n await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit)\n // The property that a delegation refactor breaks silently: a gate\n // moved downstream still refuses the caller, having already decrypted.\n expect(\n seen.decryptCalls,\n `${entry.name} read records before the export gate refused`,\n ).toEqual([])\n })\n }\n\n const importEntries = fixture.imports ?? []\n const importTitle = importEntries.length\n ? null\n : 'imports: SKIPPED — fixture declares none, so the assertCanImport gate is UNVERIFIED here'\n if (importTitle) {\n it(importTitle, () => {\n // Passes loudly. A format that ships a `decode` and declares no import\n // entries is leaving a gate unchecked, and the output should say so\n // rather than staying quiet — a documented absence, not a hole.\n expect(importEntries).toEqual([])\n })\n }\n\n for (const entry of importEntries) {\n it(`${entry.name}: SUCCEEDS on an ungated vault — otherwise its refusal below is free`, async () => {\n // Same falsifiability requirement as the export side: without an\n // importCapability grant the denial case refuses for the wrong reason.\n const vault = await fixture.vault()\n await expect(\n entry.run(vault),\n `${entry.name} failed on an ungated vault — check the importCapability grant`,\n ).resolves.toSatisfy(() => true)\n })\n\n it(`${entry.name}: REFUSES when assertCanImport denies — with the KIT'S error`, async () => {\n const seen: Observation = { decryptCalls: [] }\n const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen)\n await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit)\n })\n\n it(`${entry.name}: refuses BEFORE reading any record`, async () => {\n // Import planning READS the vault to diff against it (`diffVault`\n // routes through `exportStream`), so a gate moved after the plan\n // decrypts before refusing — the same silent break as the export side.\n const seen: Observation = { decryptCalls: [] }\n const vault = denyGates(await fixture.vault(), fixture.tier, fixture.format, seen)\n await expect(entry.run(vault)).rejects.toThrow(ExportDeniedByConformanceKit)\n expect(\n seen.decryptCalls,\n `${entry.name} read records before the import gate refused`,\n ).toEqual([])\n })\n }\n\n const writeTitle = fixture.writeWithoutAcknowledgement\n ? 'write: REFUSES without acknowledgeRisks'\n : 'write: SKIPPED — fixture declares no acknowledgement case, so the plaintext-on-disk gate is UNVERIFIED here'\n\n it(writeTitle, async () => {\n const write = fixture.writeWithoutAcknowledgement\n if (!write) {\n // Passes loudly. Omitting the case would make an unchecked security\n // gate indistinguishable from a checked one in the output.\n expect(write).toBeUndefined()\n return\n }\n const vault = await fixture.vault()\n // Matched on the MESSAGE, not merely on \"it threw\". `rejects.toThrow()`\n // alone passes when the export gate refuses first — which is exactly\n // what happened here before this line existed, and it made the case\n // unable to fail. The flag name is the one string every such message\n // contains by construction.\n await expect(write(vault, '/tmp/conformance-should-not-exist')).rejects.toThrow(\n /acknowledgeRisks/i,\n )\n })\n })\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/test-format-conformance",
|
|
3
|
-
"version": "0.7.0-pre.
|
|
3
|
+
"version": "0.7.0-pre.10",
|
|
4
4
|
"description": "Parameterized contract tests for noy-db as-* formats — the export-gate suite every plaintext projection must pass",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"vitest": "^3.0.0",
|
|
36
|
-
"@noy-db/hub": "0.7.0-pre.
|
|
36
|
+
"@noy-db/hub": "^0.7.0-pre.10"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"vitest": "^3.0.0",
|
|
40
|
-
"@noy-db/hub": "0.7.0-pre.
|
|
40
|
+
"@noy-db/hub": "0.7.0-pre.10"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"noy-db",
|