@intentius/chant 0.45.0 → 0.49.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/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +10 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +223 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +132 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +4 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +134 -8
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +211 -0
- package/src/cli/commands/build.ts +126 -8
- package/src/cli/commands/check-lexicon.test.ts +45 -1
- package/src/cli/commands/check-lexicon.ts +45 -0
- package/src/cli/commands/lexicon-surface-diff.ts +9 -0
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +32 -7
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +4 -4
- package/src/cli/handlers/lifecycle.test.ts +345 -1
- package/src/cli/handlers/lifecycle.ts +300 -11
- package/src/cli/handlers/search.ts +5 -2
- package/src/cli/main.ts +12 -1
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/registry.ts +7 -0
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/codegen/lexicon-regen.ts +19 -1
- package/src/codegen/validate.test.ts +33 -0
- package/src/codegen/validate.ts +21 -2
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +55 -0
- package/src/config.ts +88 -1
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +227 -2
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +568 -0
- package/src/lifecycle/teardown.ts +359 -0
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +10 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +179 -8
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated-once secret materialization — the one implementation behind the
|
|
3
|
+
* `ensure-secret` capability verb (components/verbs/ensure-secret.ts) and the
|
|
4
|
+
* `ensureSecret(...)` op step builder (op/builders.ts). chant #1829, epic
|
|
5
|
+
* #1365 decisions 3 and 6.
|
|
6
|
+
*
|
|
7
|
+
* The contract is read-then-write:
|
|
8
|
+
*
|
|
9
|
+
* - Absent: mint once, through the store adapter, and report `created`.
|
|
10
|
+
* - Present: verify presence, the declared key-set, and any declared
|
|
11
|
+
* metadata, then STOP — present means done. Never mint over an existing
|
|
12
|
+
* value. Never rotate implicitly. A re-run of a whole deploy leaves the
|
|
13
|
+
* stored bytes untouched.
|
|
14
|
+
* - Mismatch: fail loudly, naming what mismatched — key names and metadata
|
|
15
|
+
* keys, never a value or a value-derived hash (#1365 decision 6).
|
|
16
|
+
*
|
|
17
|
+
* The constitutional line (../secret-provenance.ts): no code path here may
|
|
18
|
+
* hold, log, hash, or compare a secret value. That is structural, not
|
|
19
|
+
* discipline:
|
|
20
|
+
*
|
|
21
|
+
* - {@link ensureSecretMaterialization} never calls the generator. It hands
|
|
22
|
+
* the generator to the store adapter's `create`, which consumes the
|
|
23
|
+
* material as it writes. The engine's result type has no field that could
|
|
24
|
+
* carry material.
|
|
25
|
+
* - The generator produces {@link SecretMaterial}, an opaque single-use
|
|
26
|
+
* handle. The plaintext lives in a module-private WeakMap, not on the
|
|
27
|
+
* object: enumeration, `JSON.stringify`, `String(...)`, and `util.inspect`
|
|
28
|
+
* all see only a redaction marker. Only {@link consumeSecretMaterial} —
|
|
29
|
+
* meant for store adapters, at the write — yields the plaintext, exactly
|
|
30
|
+
* once; a second consume throws.
|
|
31
|
+
* - `describe` returns key names and metadata only, so the mismatch check
|
|
32
|
+
* has nothing value-shaped to compare even by accident.
|
|
33
|
+
*
|
|
34
|
+
* Store adapters are per-provider (#1830 is the k8s row); core defines only
|
|
35
|
+
* the seam.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { randomBytes } from "node:crypto";
|
|
39
|
+
|
|
40
|
+
// ── Opaque secret material ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Module-private plaintext vault. Keyed by handle identity; consuming deletes. */
|
|
43
|
+
const vault = new WeakMap<SecretMaterial, string>();
|
|
44
|
+
|
|
45
|
+
const REDACTED = "[secret material]";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single-use, opaque handle to generated secret material. The plaintext is
|
|
49
|
+
* not a property of this object — it lives in a module-private WeakMap — so
|
|
50
|
+
* spreading, enumerating, stringifying, or logging the handle yields only a
|
|
51
|
+
* redaction marker. A store adapter redeems it with
|
|
52
|
+
* {@link consumeSecretMaterial} at the moment it writes; nothing else can.
|
|
53
|
+
*/
|
|
54
|
+
export class SecretMaterial {
|
|
55
|
+
private constructor() {}
|
|
56
|
+
|
|
57
|
+
/** Wrap plaintext in an opaque handle. Call inside a generator only. */
|
|
58
|
+
static mint(plaintext: string): SecretMaterial {
|
|
59
|
+
const handle = new SecretMaterial();
|
|
60
|
+
vault.set(handle, plaintext);
|
|
61
|
+
return handle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
toString(): string {
|
|
65
|
+
return REDACTED;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toJSON(): string {
|
|
69
|
+
return REDACTED;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** node:util.inspect (console.log) sees the redaction marker, not the vault. */
|
|
73
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
74
|
+
return REDACTED;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Redeem a {@link SecretMaterial} handle for its plaintext — exactly once.
|
|
80
|
+
* For store adapters only, at the write to the backing store. Throws if the
|
|
81
|
+
* handle was already consumed (or was never minted here): material flows to
|
|
82
|
+
* the store exactly once and is retained nowhere.
|
|
83
|
+
*/
|
|
84
|
+
export function consumeSecretMaterial(material: SecretMaterial): string {
|
|
85
|
+
const plaintext = vault.get(material);
|
|
86
|
+
if (plaintext === undefined) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"secret material already consumed — material flows to the store adapter exactly once and is never retained",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
vault.delete(material);
|
|
92
|
+
return plaintext;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Produces the material for one key of a secret being minted. Called by the
|
|
97
|
+
* store adapter (never by the engine) once per declared key, at create time —
|
|
98
|
+
* generation is apply-time I/O, never synthesis.
|
|
99
|
+
*/
|
|
100
|
+
export type SecretMaterialGenerator = (key: string) => SecretMaterial | Promise<SecretMaterial>;
|
|
101
|
+
|
|
102
|
+
/** 32 bytes from the CSPRNG, base64url — the default mint. */
|
|
103
|
+
export const defaultSecretMaterialGenerator: SecretMaterialGenerator = () =>
|
|
104
|
+
SecretMaterial.mint(randomBytes(32).toString("base64url"));
|
|
105
|
+
|
|
106
|
+
// ── The store adapter seam ────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** What `describe` reports about an existing secret: key NAMES and metadata. Never values. */
|
|
109
|
+
export interface SecretStoreDescription {
|
|
110
|
+
/** The key names present in the stored secret. */
|
|
111
|
+
readonly keys: readonly string[];
|
|
112
|
+
/** Store metadata (e.g. k8s labels/annotations the provider surfaces). */
|
|
113
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A provider's view of one secret store — the seam #1830 (k8s) and future
|
|
118
|
+
* provider rows implement. `create` receives the generator and consumes each
|
|
119
|
+
* key's material as it writes; no method returns material.
|
|
120
|
+
*/
|
|
121
|
+
export interface SecretStoreAdapter {
|
|
122
|
+
/** Whether a secret of this name exists in the store. */
|
|
123
|
+
exists(name: string): Promise<boolean>;
|
|
124
|
+
/** Key names and metadata of an existing secret. Never values. */
|
|
125
|
+
describe(name: string): Promise<SecretStoreDescription>;
|
|
126
|
+
/**
|
|
127
|
+
* Create the secret, minting material for each declared key via
|
|
128
|
+
* `generate` and writing it straight to the store. Returns nothing:
|
|
129
|
+
* material must not travel back through this seam.
|
|
130
|
+
*/
|
|
131
|
+
create(name: string, keys: readonly string[], generate: SecretMaterialGenerator): Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── The ensure contract ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** The declared contract to ensure — names and keys only, never material. */
|
|
137
|
+
export interface EnsureSecretSpec {
|
|
138
|
+
/** The secret's name as the store knows it. */
|
|
139
|
+
readonly name: string;
|
|
140
|
+
/** The declared key-set. Creation mints one value per key; verification compares names. */
|
|
141
|
+
readonly keys: readonly string[];
|
|
142
|
+
/** Declared metadata an existing secret must carry (compared per key; mismatches are reported by KEY). */
|
|
143
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What `ensureSecretMaterialization` did. No field can carry material. */
|
|
147
|
+
export interface EnsureSecretOutcome {
|
|
148
|
+
/** `created` = minted now (first materialization); `present` = existed and matched the contract (no write). */
|
|
149
|
+
readonly outcome: "created" | "present";
|
|
150
|
+
/** The secret's name. */
|
|
151
|
+
readonly name: string;
|
|
152
|
+
/** The declared key names the contract was checked (or minted) against. */
|
|
153
|
+
readonly keys: readonly string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The loud failure: an existing secret does not match its declared contract.
|
|
158
|
+
* `mismatches` names what differed — key names and metadata keys only.
|
|
159
|
+
* Constructing one with anything value-shaped is the reviewer's tripwire;
|
|
160
|
+
* nothing in this module can, because nothing in this module holds a value.
|
|
161
|
+
*/
|
|
162
|
+
export class SecretContractMismatchError extends Error {
|
|
163
|
+
constructor(
|
|
164
|
+
/** The secret's name. */
|
|
165
|
+
public readonly secretName: string,
|
|
166
|
+
/** Human-readable mismatch descriptions, naming keys — never values. */
|
|
167
|
+
public readonly mismatches: readonly string[],
|
|
168
|
+
) {
|
|
169
|
+
super(
|
|
170
|
+
`secret "${secretName}" exists but does not match its declared contract: ${mismatches.join("; ")}. ` +
|
|
171
|
+
`chant never mints over or rotates an existing secret — reconcile the declaration or the stored secret explicitly.`,
|
|
172
|
+
);
|
|
173
|
+
this.name = "SecretContractMismatchError";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Ensure a `generated-once` secret exists and matches its declared contract.
|
|
179
|
+
* Read-then-write:
|
|
180
|
+
*
|
|
181
|
+
* - Absent: `store.create(name, keys, generate)` — one mint, then done.
|
|
182
|
+
* - Present and matching: no write of any kind; returns `present`.
|
|
183
|
+
* - Present and mismatching: throws {@link SecretContractMismatchError}
|
|
184
|
+
* naming the missing/unexpected key names and mismatched metadata keys.
|
|
185
|
+
*
|
|
186
|
+
* The engine never calls `generate` itself and never sees what the adapter
|
|
187
|
+
* writes; its return value carries names only.
|
|
188
|
+
*/
|
|
189
|
+
export async function ensureSecretMaterialization(
|
|
190
|
+
store: SecretStoreAdapter,
|
|
191
|
+
spec: EnsureSecretSpec,
|
|
192
|
+
generate: SecretMaterialGenerator = defaultSecretMaterialGenerator,
|
|
193
|
+
): Promise<EnsureSecretOutcome> {
|
|
194
|
+
if (typeof spec.name !== "string" || spec.name.length === 0) {
|
|
195
|
+
throw new Error("ensureSecret: `name` must be a non-empty string");
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(spec.keys) || spec.keys.length === 0) {
|
|
198
|
+
throw new Error(`ensureSecret("${spec.name}"): \`keys\` must name at least one key to materialize`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (await store.exists(spec.name)) {
|
|
202
|
+
const actual = await store.describe(spec.name);
|
|
203
|
+
const mismatches: string[] = [];
|
|
204
|
+
|
|
205
|
+
const actualKeys = new Set(actual.keys);
|
|
206
|
+
const declaredKeys = new Set(spec.keys);
|
|
207
|
+
const missing = spec.keys.filter((k) => !actualKeys.has(k));
|
|
208
|
+
const unexpected = actual.keys.filter((k) => !declaredKeys.has(k));
|
|
209
|
+
if (missing.length > 0) {
|
|
210
|
+
mismatches.push(`missing declared key(s): ${missing.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
if (unexpected.length > 0) {
|
|
213
|
+
mismatches.push(`undeclared key(s) present: ${unexpected.join(", ")}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const [key, value] of Object.entries(spec.metadata ?? {})) {
|
|
217
|
+
const actualValue = actual.metadata?.[key];
|
|
218
|
+
if (actualValue === undefined) {
|
|
219
|
+
mismatches.push(`missing declared metadata key: ${key}`);
|
|
220
|
+
} else if (actualValue !== value) {
|
|
221
|
+
// Name the KEY only — metadata values stay out of the error by rule,
|
|
222
|
+
// the same line the whole module holds for secret values.
|
|
223
|
+
mismatches.push(`metadata key differs: ${key}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (mismatches.length > 0) {
|
|
228
|
+
throw new SecretContractMismatchError(spec.name, mismatches);
|
|
229
|
+
}
|
|
230
|
+
return { outcome: "present", name: spec.name, keys: spec.keys };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await store.create(spec.name, spec.keys, generate);
|
|
234
|
+
return { outcome: "created", name: spec.name, keys: spec.keys };
|
|
235
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join, dirname, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
declareSecret,
|
|
8
|
+
isSecretDeclaration,
|
|
9
|
+
isCommittedEncryptedSecret,
|
|
10
|
+
collectSecretDeclarations,
|
|
11
|
+
SECRET_DECLARATION_MARKER,
|
|
12
|
+
SECRET_DECLARATION_ENTITY_TYPE,
|
|
13
|
+
SECRET_PROVENANCE_KINDS,
|
|
14
|
+
type SecretDeclaration,
|
|
15
|
+
} from "./secret-provenance";
|
|
16
|
+
import { isDeclarable, type Declarable } from "./declarable";
|
|
17
|
+
import { build, partitionByLexicon } from "./build";
|
|
18
|
+
import type { Serializer } from "./serializer";
|
|
19
|
+
|
|
20
|
+
describe("declareSecret — referenced", () => {
|
|
21
|
+
test("carries name, kind, and optional scope — nothing else", () => {
|
|
22
|
+
const decl = declareSecret({ name: "db-password", provenance: "referenced", scope: "ns:fountain" });
|
|
23
|
+
expect(decl.name).toBe("db-password");
|
|
24
|
+
expect(decl.provenance).toBe("referenced");
|
|
25
|
+
expect(decl.scope).toBe("ns:fountain");
|
|
26
|
+
expect(decl.lexicon).toBe("chant");
|
|
27
|
+
expect(decl.entityType).toBe(SECRET_DECLARATION_ENTITY_TYPE);
|
|
28
|
+
expect(Object.keys(decl).sort()).toEqual(["entityType", "lexicon", "name", "provenance", "scope"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("scope is optional", () => {
|
|
32
|
+
const decl = declareSecret({ name: "api-token", provenance: "referenced" });
|
|
33
|
+
expect("scope" in decl).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("declareSecret — from-provider", () => {
|
|
38
|
+
test("points at the provider binding, never re-models it", () => {
|
|
39
|
+
const decl = declareSecret({
|
|
40
|
+
name: "fountain-secrets",
|
|
41
|
+
provenance: "from-provider",
|
|
42
|
+
provider: { binding: "fountainInfisical", entityType: "K8s::Infisical::InfisicalSecret" },
|
|
43
|
+
});
|
|
44
|
+
expect(decl.provenance).toBe("from-provider");
|
|
45
|
+
expect(decl.provider.binding).toBe("fountainInfisical");
|
|
46
|
+
expect(decl.provider.entityType).toBe("K8s::Infisical::InfisicalSecret");
|
|
47
|
+
expect(Object.isFrozen(decl.provider)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("requires a non-empty provider.binding", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
declareSecret({ name: "x", provenance: "from-provider", provider: { binding: "" } }),
|
|
53
|
+
).toThrow(/provider\.binding/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("declareSecret — generated-once", () => {
|
|
58
|
+
test("carries contract flags only — the declared key-set", () => {
|
|
59
|
+
const decl = declareSecret({
|
|
60
|
+
name: "master-secrets-key",
|
|
61
|
+
provenance: "generated-once",
|
|
62
|
+
keys: ["MASTER_SECRETS_KEY"],
|
|
63
|
+
});
|
|
64
|
+
expect(decl.provenance).toBe("generated-once");
|
|
65
|
+
expect(decl.keys).toEqual(["MASTER_SECRETS_KEY"]);
|
|
66
|
+
expect(Object.isFrozen(decl.keys)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("keys is optional", () => {
|
|
70
|
+
const decl = declareSecret({ name: "seed", provenance: "generated-once" });
|
|
71
|
+
expect("keys" in decl).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("declareSecret — committed-encrypted", () => {
|
|
76
|
+
test("records a path and nothing else — the bytes stay on disk", () => {
|
|
77
|
+
const decl = declareSecret({
|
|
78
|
+
name: "db-credentials",
|
|
79
|
+
provenance: "committed-encrypted",
|
|
80
|
+
file: "secrets/db-credentials.sops.yaml",
|
|
81
|
+
encryption: "sops",
|
|
82
|
+
recipients: ["age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"],
|
|
83
|
+
keys: ["POSTGRES_USER", "POSTGRES_PASSWORD"],
|
|
84
|
+
});
|
|
85
|
+
expect(decl.provenance).toBe("committed-encrypted");
|
|
86
|
+
expect(decl.file).toBe("secrets/db-credentials.sops.yaml");
|
|
87
|
+
expect(decl.encryption).toBe("sops");
|
|
88
|
+
expect(decl.keys).toEqual(["POSTGRES_USER", "POSTGRES_PASSWORD"]);
|
|
89
|
+
expect(Object.isFrozen(decl.keys)).toBe(true);
|
|
90
|
+
expect(Object.isFrozen(decl.recipients)).toBe(true);
|
|
91
|
+
expect(Object.keys(decl).sort()).toEqual([
|
|
92
|
+
"encryption",
|
|
93
|
+
"entityType",
|
|
94
|
+
"file",
|
|
95
|
+
"keys",
|
|
96
|
+
"lexicon",
|
|
97
|
+
"name",
|
|
98
|
+
"provenance",
|
|
99
|
+
"recipients",
|
|
100
|
+
]);
|
|
101
|
+
expect(isCommittedEncryptedSecret(decl)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("encryption defaults to sops; recipients and keys are optional", () => {
|
|
105
|
+
const decl = declareSecret({
|
|
106
|
+
name: "db-credentials",
|
|
107
|
+
provenance: "committed-encrypted",
|
|
108
|
+
file: "secrets/db.sops.yaml",
|
|
109
|
+
});
|
|
110
|
+
expect(decl.encryption).toBe("sops");
|
|
111
|
+
expect("recipients" in decl).toBe(false);
|
|
112
|
+
expect("keys" in decl).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the factory is pure — it never touches the filesystem", () => {
|
|
116
|
+
// A path that does not exist anywhere declares fine: existence is checked
|
|
117
|
+
// at buildRoots(), not here, which is what keeps the factory foldable.
|
|
118
|
+
const decl = declareSecret({
|
|
119
|
+
name: "nope",
|
|
120
|
+
provenance: "committed-encrypted",
|
|
121
|
+
file: "no/such/dir/nothing-here.sops.yaml",
|
|
122
|
+
});
|
|
123
|
+
expect(decl.file).toBe("no/such/dir/nothing-here.sops.yaml");
|
|
124
|
+
expect(isSecretDeclaration(decl)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("the same input twice yields equal declarations (foldable)", () => {
|
|
128
|
+
const input = {
|
|
129
|
+
name: "db-credentials",
|
|
130
|
+
provenance: "committed-encrypted",
|
|
131
|
+
file: "secrets/db.sops.yaml",
|
|
132
|
+
keys: ["A"],
|
|
133
|
+
} as const;
|
|
134
|
+
const a = declareSecret({ ...input });
|
|
135
|
+
const b = declareSecret({ ...input });
|
|
136
|
+
expect({ ...a }).toEqual({ ...b });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("rejects a missing, absolute, or escaping path", () => {
|
|
140
|
+
expect(() =>
|
|
141
|
+
// @ts-expect-error — `file` is required
|
|
142
|
+
declareSecret({ name: "x", provenance: "committed-encrypted" }),
|
|
143
|
+
).toThrow(/`file`/);
|
|
144
|
+
expect(() =>
|
|
145
|
+
declareSecret({ name: "x", provenance: "committed-encrypted", file: "" }),
|
|
146
|
+
).toThrow(/`file`/);
|
|
147
|
+
expect(() =>
|
|
148
|
+
declareSecret({ name: "x", provenance: "committed-encrypted", file: "/etc/shadow.yaml" }),
|
|
149
|
+
).toThrow(/repo-relative/);
|
|
150
|
+
expect(() =>
|
|
151
|
+
declareSecret({ name: "x", provenance: "committed-encrypted", file: "../../secrets/x.yaml" }),
|
|
152
|
+
).toThrow(/\.\./);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("v1 restricts the path to .yaml/.yml", () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
declareSecret({ name: "x", provenance: "committed-encrypted", file: "secrets/x.sops.json" }),
|
|
158
|
+
).toThrow(/YAML/);
|
|
159
|
+
expect(
|
|
160
|
+
declareSecret({ name: "x", provenance: "committed-encrypted", file: "secrets/x.sops.YML" })
|
|
161
|
+
.file,
|
|
162
|
+
).toBe("secrets/x.sops.YML");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects an unknown encryption tool", () => {
|
|
166
|
+
expect(() =>
|
|
167
|
+
declareSecret({
|
|
168
|
+
name: "x",
|
|
169
|
+
provenance: "committed-encrypted",
|
|
170
|
+
file: "secrets/x.yaml",
|
|
171
|
+
// @ts-expect-error — the encryption union is closed
|
|
172
|
+
encryption: "gpg",
|
|
173
|
+
}),
|
|
174
|
+
).toThrow(/unknown encryption/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("refuses a private key pasted into recipients, without echoing it", () => {
|
|
178
|
+
const identity =
|
|
179
|
+
"AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
|
|
180
|
+
let thrown: Error | undefined;
|
|
181
|
+
try {
|
|
182
|
+
declareSecret({
|
|
183
|
+
name: "x",
|
|
184
|
+
provenance: "committed-encrypted",
|
|
185
|
+
file: "secrets/x.yaml",
|
|
186
|
+
recipients: [identity],
|
|
187
|
+
});
|
|
188
|
+
} catch (e) {
|
|
189
|
+
thrown = e as Error;
|
|
190
|
+
}
|
|
191
|
+
expect(thrown).toBeDefined();
|
|
192
|
+
expect(thrown!.message).toContain("recipients[0]");
|
|
193
|
+
expect(thrown!.message).not.toContain(identity);
|
|
194
|
+
|
|
195
|
+
expect(() =>
|
|
196
|
+
declareSecret({
|
|
197
|
+
name: "x",
|
|
198
|
+
provenance: "committed-encrypted",
|
|
199
|
+
file: "secrets/x.yaml",
|
|
200
|
+
recipients: ["-----BEGIN OPENSSH PRIVATE KEY-----"],
|
|
201
|
+
}),
|
|
202
|
+
).toThrow(/PRIVATE key/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("`ciphertext` is still forbidden — the declaration points at bytes, never carries them", () => {
|
|
206
|
+
const input = {
|
|
207
|
+
name: "x",
|
|
208
|
+
provenance: "committed-encrypted",
|
|
209
|
+
file: "secrets/x.yaml",
|
|
210
|
+
ciphertext: "ENC[AES256_GCM,data:xxxx]",
|
|
211
|
+
};
|
|
212
|
+
let thrown: Error | undefined;
|
|
213
|
+
try {
|
|
214
|
+
// @ts-expect-error — `ciphertext` must not compile
|
|
215
|
+
declareSecret(input);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
thrown = e as Error;
|
|
218
|
+
}
|
|
219
|
+
expect(thrown).toBeDefined();
|
|
220
|
+
expect(thrown!.message).toContain('"ciphertext"');
|
|
221
|
+
expect(thrown!.message).not.toContain("AES256_GCM");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("declareSecret — value unrepresentable", () => {
|
|
226
|
+
test("no declaration kind has a field that could hold material", () => {
|
|
227
|
+
const decls: SecretDeclaration[] = [
|
|
228
|
+
declareSecret({ name: "a", provenance: "referenced" }),
|
|
229
|
+
declareSecret({ name: "b", provenance: "from-provider", provider: { binding: "p" } }),
|
|
230
|
+
declareSecret({ name: "c", provenance: "generated-once", keys: ["k"] }),
|
|
231
|
+
declareSecret({ name: "d", provenance: "committed-encrypted", file: "s/d.sops.yaml" }),
|
|
232
|
+
];
|
|
233
|
+
for (const decl of decls) {
|
|
234
|
+
for (const field of ["value", "data", "stringData", "material", "plaintext", "ciphertext"]) {
|
|
235
|
+
expect(field in decl).toBe(false);
|
|
236
|
+
}
|
|
237
|
+
// Declared fields are immutable (the object stays extensible so
|
|
238
|
+
// discovery can stamp its symbol-keyed metadata).
|
|
239
|
+
expect(() => {
|
|
240
|
+
(decl as { name: string }).name = "mutated";
|
|
241
|
+
}).toThrow();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a material-shaped field is rejected at runtime, naming only the key", () => {
|
|
246
|
+
const input = { name: "leaky", provenance: "referenced", value: "hunter2" };
|
|
247
|
+
let thrown: Error | undefined;
|
|
248
|
+
try {
|
|
249
|
+
// @ts-expect-error — a value field must not compile
|
|
250
|
+
declareSecret(input);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
thrown = e as Error;
|
|
253
|
+
}
|
|
254
|
+
expect(thrown).toBeDefined();
|
|
255
|
+
expect(thrown!.message).toContain('"value"');
|
|
256
|
+
// The constitutional line: the error must not echo the material.
|
|
257
|
+
expect(thrown!.message).not.toContain("hunter2");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("type-level: material fields do not compile on any kind", () => {
|
|
261
|
+
// @ts-expect-error — `value` is unrepresentable
|
|
262
|
+
const a = () => declareSecret({ name: "a", provenance: "referenced", value: "s" });
|
|
263
|
+
// @ts-expect-error — `data` is unrepresentable
|
|
264
|
+
const b = () => declareSecret({ name: "b", provenance: "generated-once", data: { k: "v" } });
|
|
265
|
+
const c = () =>
|
|
266
|
+
// @ts-expect-error — `stringData` is unrepresentable
|
|
267
|
+
declareSecret({ name: "c", provenance: "from-provider", provider: { binding: "p" }, stringData: {} });
|
|
268
|
+
// Widened objects (past excess-property checks) are still rejected by the `never` fields.
|
|
269
|
+
const widened = { name: "d", provenance: "referenced", value: "s" } as const;
|
|
270
|
+
// @ts-expect-error — `value` stays unrepresentable through a widened object
|
|
271
|
+
const d = () => declareSecret(widened);
|
|
272
|
+
expect([a, b, c, d].every((f) => typeof f === "function")).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("rejects an empty name and an unknown kind", () => {
|
|
276
|
+
expect(() => declareSecret({ name: "", provenance: "referenced" })).toThrow(/name/);
|
|
277
|
+
// @ts-expect-error — the union is closed
|
|
278
|
+
expect(() => declareSecret({ name: "x", provenance: "sealed-secret" })).toThrow(/unknown provenance/);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("secret declarations and the entity map", () => {
|
|
283
|
+
test("a declaration is a Declarable and a SecretDeclaration; the kind set is closed", () => {
|
|
284
|
+
const decl = declareSecret({ name: "a", provenance: "referenced" });
|
|
285
|
+
expect(isDeclarable(decl)).toBe(true);
|
|
286
|
+
expect(isSecretDeclaration(decl)).toBe(true);
|
|
287
|
+
expect(isSecretDeclaration({ name: "a", provenance: "referenced" })).toBe(false);
|
|
288
|
+
expect((decl as unknown as Record<symbol, unknown>)[SECRET_DECLARATION_MARKER]).toBe(true);
|
|
289
|
+
expect(SECRET_PROVENANCE_KINDS).toEqual([
|
|
290
|
+
"referenced",
|
|
291
|
+
"from-provider",
|
|
292
|
+
"generated-once",
|
|
293
|
+
"committed-encrypted",
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("collectSecretDeclarations extracts declarations by entity name", () => {
|
|
298
|
+
const other = {
|
|
299
|
+
lexicon: "test",
|
|
300
|
+
entityType: "Test::Thing",
|
|
301
|
+
[Symbol.for("chant.declarable")]: true,
|
|
302
|
+
} as unknown as Declarable;
|
|
303
|
+
const entities = new Map<string, Declarable>([
|
|
304
|
+
["thing", other],
|
|
305
|
+
["dbPassword", declareSecret({ name: "db-password", provenance: "generated-once" })],
|
|
306
|
+
]);
|
|
307
|
+
const secrets = collectSecretDeclarations(entities);
|
|
308
|
+
expect([...secrets.keys()]).toEqual(["dbPassword"]);
|
|
309
|
+
expect(secrets.get("dbPassword")!.name).toBe("db-password");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("partitionByLexicon excludes declarations from every partition", () => {
|
|
313
|
+
const entities = new Map<string, Declarable>([
|
|
314
|
+
[
|
|
315
|
+
"thing",
|
|
316
|
+
{
|
|
317
|
+
lexicon: "test",
|
|
318
|
+
entityType: "Test::Thing",
|
|
319
|
+
[Symbol.for("chant.declarable")]: true,
|
|
320
|
+
} as unknown as Declarable,
|
|
321
|
+
],
|
|
322
|
+
["secret", declareSecret({ name: "s", provenance: "referenced" })],
|
|
323
|
+
]);
|
|
324
|
+
const partitions = partitionByLexicon(entities);
|
|
325
|
+
expect([...partitions.keys()]).toEqual(["test"]);
|
|
326
|
+
expect(partitions.get("test")!.has("secret")).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("serializer neutrality (#1828 acceptance)", () => {
|
|
331
|
+
test("a built project's outputs never contain a declaration, and no serializer receives one", async () => {
|
|
332
|
+
const testDir = join(tmpdir(), `chant-secret-prov-${Date.now()}-${Math.random()}`);
|
|
333
|
+
await mkdir(testDir, { recursive: true });
|
|
334
|
+
try {
|
|
335
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
336
|
+
const modulePath = resolvePath(thisDir, "secret-provenance");
|
|
337
|
+
await writeFile(
|
|
338
|
+
join(testDir, "secrets.infra.ts"),
|
|
339
|
+
`
|
|
340
|
+
import { declareSecret } from ${JSON.stringify(modulePath)};
|
|
341
|
+
export const dbPassword = declareSecret({ name: "db-password", provenance: "generated-once", keys: ["password"] });
|
|
342
|
+
export const apiToken = declareSecret({ name: "api-token", provenance: "referenced", scope: "vault:prod" });
|
|
343
|
+
export const providerFed = declareSecret({ name: "fountain-secrets", provenance: "from-provider", provider: { binding: "fountainInfisical" } });
|
|
344
|
+
export const realResource = {
|
|
345
|
+
lexicon: "test",
|
|
346
|
+
entityType: "Test::Thing",
|
|
347
|
+
[Symbol.for("chant.declarable")]: true,
|
|
348
|
+
};
|
|
349
|
+
`,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const seen: string[] = [];
|
|
353
|
+
const testSerializer: Serializer = {
|
|
354
|
+
name: "test",
|
|
355
|
+
rulePrefix: "TEST",
|
|
356
|
+
serialize: (entities) => {
|
|
357
|
+
seen.push(...entities.keys());
|
|
358
|
+
return JSON.stringify([...entities.keys()]);
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const result = await build(testDir, [testSerializer]);
|
|
363
|
+
|
|
364
|
+
expect(result.errors).toEqual([]);
|
|
365
|
+
// Discovery found all four exports.
|
|
366
|
+
expect(result.entities.size).toBe(4);
|
|
367
|
+
expect(isSecretDeclaration(result.entities.get("dbPassword")!)).toBe(true);
|
|
368
|
+
const collected = collectSecretDeclarations(result.entities);
|
|
369
|
+
expect([...collected.keys()].sort()).toEqual(["apiToken", "dbPassword", "providerFed"]);
|
|
370
|
+
|
|
371
|
+
// No serializer received a declaration, no output mentions one, and the
|
|
372
|
+
// declarations' pseudo-lexicon produced neither an output nor a warning.
|
|
373
|
+
expect(seen).toEqual(["realResource"]);
|
|
374
|
+
expect([...result.outputs.keys()]).toEqual(["test"]);
|
|
375
|
+
for (const output of result.outputs.values()) {
|
|
376
|
+
const text = typeof output === "string" ? output : JSON.stringify(output);
|
|
377
|
+
expect(text).not.toContain("db-password");
|
|
378
|
+
expect(text).not.toContain("api-token");
|
|
379
|
+
expect(text).not.toContain("fountain-secrets");
|
|
380
|
+
}
|
|
381
|
+
expect(result.warnings.filter((w) => w.includes("No serializer"))).toEqual([]);
|
|
382
|
+
// The manifest's stack list has no pseudo-stack for the declarations.
|
|
383
|
+
expect(result.manifest.lexicons).toEqual(["test"]);
|
|
384
|
+
} finally {
|
|
385
|
+
await rm(testDir, { recursive: true, force: true });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|