@ashlr/stack-core 0.3.1 → 0.3.6
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/package.json +1 -1
- package/src/__tests__/_harness.ts +65 -39
- package/src/__tests__/apply-placeholder-history.test.ts +55 -0
- package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
- package/src/__tests__/apply-terminology-examples.test.ts +46 -0
- package/src/__tests__/config.test.ts +36 -0
- package/src/__tests__/credential-staging.test.ts +7 -6
- package/src/__tests__/deps.test.ts +35 -0
- package/src/__tests__/detect-source.test.ts +97 -0
- package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
- package/src/__tests__/phantom-process.test.ts +21 -3
- package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
- package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
- package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
- package/src/__tests__/phantom-wire.test.ts +6329 -363
- package/src/__tests__/pipeline-integration.test.ts +26 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
- package/src/__tests__/secret-redaction.test.ts +13 -3
- package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
- package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
- package/src/ai/phantom-wire-args.test.ts +63 -0
- package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
- package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
- package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
- package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
- package/src/ai/phantom-wire-crlf.test.ts +48 -0
- package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
- package/src/ai/phantom-wire-double-quote.test.ts +135 -0
- package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
- package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
- package/src/ai/phantom-wire-empty-template.test.ts +81 -0
- package/src/ai/phantom-wire-empty-values.test.ts +129 -0
- package/src/ai/phantom-wire-errors.test.ts +296 -0
- package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
- package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
- package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
- package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
- package/src/ai/phantom-wire-missing.test.ts +123 -0
- package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
- package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
- package/src/ai/phantom-wire-newline.test.ts +136 -0
- package/src/ai/phantom-wire-order.test.ts +173 -0
- package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
- package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
- package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
- package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
- package/src/ai/phantom-wire-single-quote.test.ts +22 -0
- package/src/ai/phantom-wire-tab.test.ts +42 -0
- package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
- package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
- package/src/ai/phantom-wire.test.ts +144 -0
- package/src/ai/phantom-wire.ts +11 -4
- package/src/phantom.ts +10 -1
- package/src/stack-manifest.ts +3 -3
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { verificationHintFor, webhookStubFor, wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// The wiring check spawns the fake `phantom` shim (bash + python3); keep the
|
|
10
|
+
// sibling files' raised timeout.
|
|
11
|
+
setDefaultTimeout(30_000);
|
|
12
|
+
|
|
13
|
+
describe("ai/phantom-wire known placeholder as the entire template", () => {
|
|
14
|
+
let harness: Harness;
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
harness = setupFakePhantom();
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "wire-known-placeholder-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness.cleanup();
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
27
|
+
id: "known-placeholder",
|
|
28
|
+
query: "q",
|
|
29
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
30
|
+
providers: names.map((name) => ({ name })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("preserves punctuation adjacent to known placeholder replacements", () => {
|
|
34
|
+
const stub = webhookStubFor("stripe");
|
|
35
|
+
|
|
36
|
+
expect(stub).toContain(
|
|
37
|
+
"// Auto-generated by `stack apply` — webhook handler stub for stripe.\n",
|
|
38
|
+
);
|
|
39
|
+
expect(stub).toContain('// const signature = req.headers.get("stripe-signature");\n');
|
|
40
|
+
expect(stub).toContain("// // (`phantom reveal STRIPE_WEBHOOK_SECRET`) and compare in\n");
|
|
41
|
+
expect(stub).toContain(
|
|
42
|
+
"// export async function handleStripeWebhook(req: Request): Promise<Response> {\n",
|
|
43
|
+
);
|
|
44
|
+
expect(stub).not.toContain("${");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it.each([
|
|
48
|
+
"${provider}", // the name slot's own spelling
|
|
49
|
+
"${capitalize(provider)}", // the handler-name slot's spelling
|
|
50
|
+
"${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
|
|
51
|
+
"${signatureHeaderFor(provider)}", // the header slot's spelling
|
|
52
|
+
"${verificationHintFor(provider)}", // the hint slot's spelling
|
|
53
|
+
])("emits the replacement value itself at every site for the whole-name token %j", (name) => {
|
|
54
|
+
// The name is exactly one of the template's slot spellings — the
|
|
55
|
+
// placeholder is the entire input, with no ordinary text around it.
|
|
56
|
+
// At each expansion site the emitted output equals the replacement
|
|
57
|
+
// value (the name, in that site's case); the token-shaped value is
|
|
58
|
+
// never fed back through the slots — a second pass would erase or
|
|
59
|
+
// rewrite it, and none runs.
|
|
60
|
+
const stub = webhookStubFor(name);
|
|
61
|
+
|
|
62
|
+
// Case-preserving sites: the output is the replacement value
|
|
63
|
+
// byte-for-byte. `capitalize` folds only the first code point, and
|
|
64
|
+
// `$` uppercases to itself, so the handler site is identical too.
|
|
65
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
66
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
67
|
+
expect(stub).toContain(`handle${name}Webhook(req: Request)`);
|
|
68
|
+
// Both `toUpperCase()` secret-key sites hold the whole-name-uppercased
|
|
69
|
+
// value — the fold happens inside the replacement, proof the value was
|
|
70
|
+
// substituted (an unresolved slot would sit in template case).
|
|
71
|
+
expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
|
|
72
|
+
|
|
73
|
+
// Five `${...}` spans — one per expansion site — and every one is the
|
|
74
|
+
// carried token in that site's case, none resolved away or duplicated.
|
|
75
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
76
|
+
expect(tokens).toHaveLength(5);
|
|
77
|
+
for (const token of tokens) expect(token.toLowerCase()).toBe(name.toLowerCase());
|
|
78
|
+
|
|
79
|
+
// The remaining slots took their own values in the same single pass:
|
|
80
|
+
// the generic signature header and the fallback hint — the carried
|
|
81
|
+
// token never reached their switch cases either.
|
|
82
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
83
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
84
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each([
|
|
88
|
+
["stripe", "Stripe: `stripe.webhooks.constructEvent` — HMAC-SHA256 with timestamp tolerance."],
|
|
89
|
+
["clerk", "Clerk (svix): `new Webhook(secret).verify(body, headers)`."],
|
|
90
|
+
["supabase", "Supabase: `x-supabase-signature` per Supabase's documented webhook scheme."],
|
|
91
|
+
["github", "GitHub: `x-hub-signature-256` is `sha256=` + HMAC-SHA256 of the raw body."],
|
|
92
|
+
])("renders the %s hint slot as exactly its replacement value", (provider, hint) => {
|
|
93
|
+
// A known provider name is the whole template of the hint slot: the
|
|
94
|
+
// render is exactly the fixed replacement — no wrapper, no leftover
|
|
95
|
+
// `${...}` — and the stub carries that value once, on the TODO line.
|
|
96
|
+
expect(verificationHintFor(provider)).toBe(hint);
|
|
97
|
+
const stub = webhookStubFor(provider);
|
|
98
|
+
expect(stub.split(`// // ${hint}\n`).length - 1).toBe(1);
|
|
99
|
+
expect(stub).not.toContain("${");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("emits the name where the `${provider}` slot stood — output equals the value it resolved to", () => {
|
|
103
|
+
// The name spells the `provider` slot itself, so at the name sites the
|
|
104
|
+
// emitted text is indistinguishable from an unfilled slot. The proof
|
|
105
|
+
// the output is the *replacement value* and not a skipped slot: the
|
|
106
|
+
// two reveal sites carry `${PROVIDER}` — the value uppercased, work
|
|
107
|
+
// only the substitution path does — while the sibling slots resolved
|
|
108
|
+
// to their own real values. A recursive pass would have re-resolved
|
|
109
|
+
// the carried `${provider}` into something else; every occurrence
|
|
110
|
+
// stays the literal value.
|
|
111
|
+
const name = "${provider}";
|
|
112
|
+
const stub = webhookStubFor(name);
|
|
113
|
+
|
|
114
|
+
// The slot's replacement IS the name: at all three name sites the
|
|
115
|
+
// rendered text equals the value that was passed in.
|
|
116
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
117
|
+
expect(stub).toContain(`handle${name}Webhook(req: Request)`);
|
|
118
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
119
|
+
expect(stub.split("${provider}").length - 1).toBe(3);
|
|
120
|
+
// Folded at the reveal sites — substituted, not left raw. The folded
|
|
121
|
+
// token appears exactly twice, each time inside a `phantom reveal` line.
|
|
122
|
+
expect(stub.split("phantom reveal ${PROVIDER}_WEBHOOK_SECRET").length - 1).toBe(2);
|
|
123
|
+
expect(stub.split("${PROVIDER}").length - 1).toBe(2);
|
|
124
|
+
|
|
125
|
+
// Single pass: the header and hint slots took real values; the carried
|
|
126
|
+
// `${provider}` token was never offered back to any slot.
|
|
127
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
128
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
129
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
130
|
+
expect(tokens).toEqual([
|
|
131
|
+
"${provider}",
|
|
132
|
+
"${PROVIDER}",
|
|
133
|
+
"${provider}",
|
|
134
|
+
"${provider}",
|
|
135
|
+
"${PROVIDER}",
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("skips a whole-name known-placeholder token byte-for-byte through wiring and writes nothing for it", async () => {
|
|
140
|
+
// Through `wirePhantomForRecipe` the slot-spelling name matches no
|
|
141
|
+
// catalog provider: it returns in `skipped` byte-for-byte — the entire
|
|
142
|
+
// token intact — gets no stub on disk, and provokes no vault write.
|
|
143
|
+
// The known provider listed beside it wires normally, unaffected by
|
|
144
|
+
// the placeholder-shaped neighbour.
|
|
145
|
+
const name = "${webhookSecretKeyFor(provider)}";
|
|
146
|
+
const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
|
|
147
|
+
const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
|
|
148
|
+
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
present: [],
|
|
151
|
+
missing: ["clerk:CLERK_SECRET_KEY"],
|
|
152
|
+
webhooks: [stubPath],
|
|
153
|
+
skipped: [name],
|
|
154
|
+
});
|
|
155
|
+
expect(result.skipped[0]).toBe(name);
|
|
156
|
+
|
|
157
|
+
// Only clerk's documented stub lands — nothing for the placeholder
|
|
158
|
+
// name, and the stub on disk is the same render `webhookStubFor`
|
|
159
|
+
// returns.
|
|
160
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
|
|
161
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
|
|
162
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
163
|
+
|
|
164
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
165
|
+
// still parses to an empty object.
|
|
166
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
167
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
168
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { StackError } from "../errors.ts";
|
|
17
|
+
import { __resetPhantomCache, __setPhantomSpawn, listSecrets } from "../phantom.ts";
|
|
18
|
+
import { wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
19
|
+
import type { Recipe } from "./recipe.ts";
|
|
20
|
+
|
|
21
|
+
// Stubbed `phantom` subprocess, like the sibling phantom-wire-*.test.ts files:
|
|
22
|
+
// `--version` succeeds and `list --json` emits whatever bytes `listOutput`
|
|
23
|
+
// holds. Because spawn itself is replaced, no child process — and no socket —
|
|
24
|
+
// ever exists: the malformed-inventory path provably makes no network access.
|
|
25
|
+
// `calls` records every argv so the tests can pin the read-only verbs
|
|
26
|
+
// (`--version`, `list --json`) and exclude every mutating one.
|
|
27
|
+
let installed = true;
|
|
28
|
+
let listOutput: string[] | "unreadable" = ["[]"];
|
|
29
|
+
let calls: string[][] = [];
|
|
30
|
+
|
|
31
|
+
function fakeSpawn(_bin: string, args: string[]) {
|
|
32
|
+
calls.push([...args]);
|
|
33
|
+
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
|
|
34
|
+
const out = new EventEmitter();
|
|
35
|
+
const err = new EventEmitter();
|
|
36
|
+
Object.assign(child, {
|
|
37
|
+
stdout: Object.assign(out, { destroy() {} }),
|
|
38
|
+
stderr: Object.assign(err, { destroy() {} }),
|
|
39
|
+
stdin: { on() {}, write() {}, end() {}, destroy() {} },
|
|
40
|
+
pid: undefined,
|
|
41
|
+
kill() {},
|
|
42
|
+
unref() {},
|
|
43
|
+
});
|
|
44
|
+
queueMicrotask(() => {
|
|
45
|
+
if (!installed) {
|
|
46
|
+
child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (args[0] === "list") {
|
|
50
|
+
if (listOutput === "unreadable") {
|
|
51
|
+
child.emit("close", 1, null);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Emit each element as its own `data` event: exec() reassembles stdout
|
|
55
|
+
// from chunks, and only the joined bytes are parsed.
|
|
56
|
+
for (const chunk of listOutput) out.emit("data", Buffer.from(chunk));
|
|
57
|
+
}
|
|
58
|
+
child.emit("close", 0, null);
|
|
59
|
+
});
|
|
60
|
+
return child;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function recipeOf(...names: string[]): Recipe {
|
|
64
|
+
return {
|
|
65
|
+
id: "malformed-json",
|
|
66
|
+
query: "malformed",
|
|
67
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
68
|
+
providers: names.map((name) => ({ name })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function listSecretsError(cwd: string): Promise<unknown> {
|
|
73
|
+
try {
|
|
74
|
+
await listSecrets(cwd);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return err;
|
|
77
|
+
}
|
|
78
|
+
throw new Error("listSecrets resolved; expected a rejection");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("ai/phantom-wire malformed vault inventory JSON", () => {
|
|
82
|
+
const savedPath = process.env.PATH;
|
|
83
|
+
let binDir: string;
|
|
84
|
+
let projectDir: string;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
binDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-badjson-bin-")));
|
|
88
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-badjson-")));
|
|
89
|
+
// Binary only needs to resolve on PATH; spawn is stubbed. `.sh` target
|
|
90
|
+
// matches the sibling tests (macOS provenance policy).
|
|
91
|
+
writeFileSync(join(binDir, "phantom.sh"), "#!/bin/sh\n");
|
|
92
|
+
chmodSync(join(binDir, "phantom.sh"), 0o755);
|
|
93
|
+
symlinkSync(join(binDir, "phantom.sh"), join(binDir, "phantom"));
|
|
94
|
+
process.env.PATH = binDir;
|
|
95
|
+
installed = true;
|
|
96
|
+
listOutput = ["[]"];
|
|
97
|
+
calls = [];
|
|
98
|
+
__resetPhantomCache();
|
|
99
|
+
__setPhantomSpawn(fakeSpawn as never);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
process.env.PATH = savedPath;
|
|
104
|
+
rmSync(binDir, { recursive: true, force: true });
|
|
105
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterAll(() => {
|
|
109
|
+
__setPhantomSpawn();
|
|
110
|
+
__resetPhantomCache();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it.each([
|
|
114
|
+
// A zero-byte answer is a successful exit with nothing to parse.
|
|
115
|
+
["empty stdout", [""]],
|
|
116
|
+
["whitespace only", [" \n\t "]],
|
|
117
|
+
// Truncated mid-entry: the bytes still carry a plausible secret name,
|
|
118
|
+
// which must never be reported as present.
|
|
119
|
+
["truncated mid-entry", ['[{"name":"STRIPE_SECRET_KEY"']],
|
|
120
|
+
// A valid array followed by junk: JSON.parse rejects trailing content.
|
|
121
|
+
["trailing garbage", ['[{"name":"STRIPE_SECRET_KEY"}]\n{"name":"EXTRA"}']],
|
|
122
|
+
// The decorated table an older Phantom prints when it ignores `--json` —
|
|
123
|
+
// the exact "upgrade Phantom Secrets" scenario the error names.
|
|
124
|
+
["human table output", ["-> 1 secret(s) in vault (encrypted-file):\n - STRIPE_SECRET_KEY\n"]],
|
|
125
|
+
// Leading BOM is not JSON whitespace.
|
|
126
|
+
["BOM-prefixed array", ['\uFEFF[{"name":"STRIPE_SECRET_KEY"}]']],
|
|
127
|
+
// Interleaved garbage across chunk boundaries is still one unparseable body.
|
|
128
|
+
["noise split across chunks", ['[{"name":"STRIPE', '"}} nonsense [']],
|
|
129
|
+
])(
|
|
130
|
+
"reports %s as the documented unrecognized-output error, never an uncaught SyntaxError",
|
|
131
|
+
async (_label, chunks) => {
|
|
132
|
+
listOutput = chunks;
|
|
133
|
+
|
|
134
|
+
const err = await listSecretsError(projectDir);
|
|
135
|
+
// The handler's expected error: a typed StackError with the documented
|
|
136
|
+
// code — not the raw JSON.parse SyntaxError escaping uncaught.
|
|
137
|
+
expect(err).toBeInstanceOf(StackError);
|
|
138
|
+
expect(err).not.toBeInstanceOf(SyntaxError);
|
|
139
|
+
expect(err).toMatchObject({
|
|
140
|
+
code: "PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
141
|
+
message:
|
|
142
|
+
"phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The wiring request still resolves: the unreadable inventory degrades
|
|
146
|
+
// to `skipped` with no present/missing claims — including the plausible
|
|
147
|
+
// name the truncated bytes contained — while the webhook stub is written.
|
|
148
|
+
const result = await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
|
|
149
|
+
expect(result.present).toEqual([]);
|
|
150
|
+
expect(result.missing).toEqual([]);
|
|
151
|
+
expect(result.skipped).toEqual(["stripe"]);
|
|
152
|
+
const stubPath = join(projectDir, ".stack/webhooks/stripe.ts");
|
|
153
|
+
expect(result.webhooks).toEqual([stubPath]);
|
|
154
|
+
expect(existsSync(stubPath)).toBe(true);
|
|
155
|
+
|
|
156
|
+
// Metadata only: the install probe plus two inventory reads. The spawn
|
|
157
|
+
// stub means no process or socket exists; no mutating verb ever runs.
|
|
158
|
+
expect(calls).toEqual([["--version"], ["list", "--json"], ["list", "--json"]]);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
it.each([
|
|
163
|
+
["a string entry", '["STRIPE_SECRET_KEY"]'],
|
|
164
|
+
["a null entry", "[null]"],
|
|
165
|
+
["a nested array entry", '[["STRIPE_SECRET_KEY"]]'],
|
|
166
|
+
["a numeric name", '[{"name":42}]'],
|
|
167
|
+
["an empty name", '[{"name":""}]'],
|
|
168
|
+
["an entry with no name", '[{"detected_service":null}]'],
|
|
169
|
+
// One malformed entry poisons the whole inventory: names are never
|
|
170
|
+
// partially trusted.
|
|
171
|
+
["one bad entry among valid ones", '[{"name":"OPENAI_API_KEY"},{"name":42}]'],
|
|
172
|
+
])(
|
|
173
|
+
"rejects %s in the inventory array and keeps the provider's secrets unknown",
|
|
174
|
+
async (_label, output) => {
|
|
175
|
+
listOutput = [output];
|
|
176
|
+
|
|
177
|
+
await expect(listSecrets(projectDir)).rejects.toMatchObject({
|
|
178
|
+
code: "PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
179
|
+
message: "phantom list --json contains unrecognized secret-name metadata.",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const result = await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
|
|
183
|
+
expect(result).toEqual({
|
|
184
|
+
present: [],
|
|
185
|
+
missing: [],
|
|
186
|
+
skipped: ["stripe"],
|
|
187
|
+
webhooks: [join(projectDir, ".stack/webhooks/stripe.ts")],
|
|
188
|
+
});
|
|
189
|
+
expect(calls).toEqual([["--version"], ["list", "--json"], ["list", "--json"]]);
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
it("parses inventory JSON only after all stdout chunks arrive", async () => {
|
|
194
|
+
// Each partial chunk is malformed JSON on its own; only the reassembled
|
|
195
|
+
// body parses. Guards against regressing to per-chunk parsing.
|
|
196
|
+
listOutput = ['[{"name":"STRI', "PE_SEC", 'RET_KEY"}]'];
|
|
197
|
+
|
|
198
|
+
expect(await listSecrets(projectDir)).toEqual(["STRIPE_SECRET_KEY"]);
|
|
199
|
+
const result = await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
|
|
200
|
+
expect(result.present).toEqual(["stripe:STRIPE_SECRET_KEY"]);
|
|
201
|
+
expect(result.skipped).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps malformed inventory bytes off every verb except the read-only probes", async () => {
|
|
205
|
+
listOutput = ['[{"name":"STRIPE_SECRET_KEY"'];
|
|
206
|
+
await wirePhantomForRecipe(recipeOf("stripe"), { cwd: projectDir });
|
|
207
|
+
|
|
208
|
+
// Whatever the vault answer looks like, wiring may probe the install and
|
|
209
|
+
// read names — never `add`, `remove`, `init`, `reveal`, `status`, `sync`.
|
|
210
|
+
for (const argv of calls) expect(["--version", "list"]).toContain(argv[0]);
|
|
211
|
+
expect(calls).toEqual([["--version"], ["list", "--json"]]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("makes no network access: the module has no network call sites", () => {
|
|
215
|
+
// phantom-wire.ts shells out to a local `phantom` binary and writes files;
|
|
216
|
+
// the only request handler it mentions is the commented-out stub example.
|
|
217
|
+
// Pin that wiring stays metadata-only and cannot reach the network even if
|
|
218
|
+
// a future edit is tempted to fall back to a remote probe.
|
|
219
|
+
const source = readFileSync(
|
|
220
|
+
fileURLToPath(new URL("./phantom-wire.ts", import.meta.url)),
|
|
221
|
+
"utf-8",
|
|
222
|
+
);
|
|
223
|
+
expect(source).not.toMatch(
|
|
224
|
+
/\bfetch\(|node:(http|https|net|dgram|tls)|new WebSocket|XMLHttpRequest|undici/,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { addSecret } from "../phantom.ts";
|
|
7
|
+
import { wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
8
|
+
import type { Recipe } from "./recipe.ts";
|
|
9
|
+
|
|
10
|
+
// Each check spawns the fake `phantom` shim (bash + python3); several checks
|
|
11
|
+
// per test multiply that, so keep the sibling files' raised timeout.
|
|
12
|
+
setDefaultTimeout(30_000);
|
|
13
|
+
|
|
14
|
+
describe("ai/phantom-wire repeated missing-value checks", () => {
|
|
15
|
+
let harness: Harness;
|
|
16
|
+
let dir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
harness = setupFakePhantom();
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), "stack wire missing-"));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
harness.cleanup();
|
|
24
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
28
|
+
id: "recheck",
|
|
29
|
+
query: "q",
|
|
30
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
31
|
+
providers: names.map((name) => ({ name })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const vaultText = () => readFileSync(harness.vaultPath, "utf-8");
|
|
35
|
+
// Every logged phantom call must be a read: the install probe or `list`.
|
|
36
|
+
const onlyReadCalls = () =>
|
|
37
|
+
harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list");
|
|
38
|
+
|
|
39
|
+
it("re-reports the same secrets missing on every check and never writes placeholders", async () => {
|
|
40
|
+
const before = vaultText();
|
|
41
|
+
const first = await wirePhantomForRecipe(recipe("stripe", "github"), { cwd: dir });
|
|
42
|
+
const firstMissing = [...first.missing];
|
|
43
|
+
|
|
44
|
+
// A missing secret is a promise to the user ("store with `phantom add`).
|
|
45
|
+
// Re-checking must not shrink the list, move entries to `present`, or
|
|
46
|
+
// quietly absorb them into `skipped` — each would misreport a write.
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
const again = await wirePhantomForRecipe(recipe("stripe", "github"), { cwd: dir });
|
|
49
|
+
expect(again).toEqual(first);
|
|
50
|
+
expect(again.missing).toEqual(["stripe:STRIPE_SECRET_KEY", "github:GITHUB_TOKEN"]);
|
|
51
|
+
expect(again.present).toEqual([]);
|
|
52
|
+
expect(again.skipped).toEqual([]);
|
|
53
|
+
}
|
|
54
|
+
// Each run builds a fresh result; later checks never mutate earlier ones.
|
|
55
|
+
expect(first.missing).toEqual(firstMissing);
|
|
56
|
+
|
|
57
|
+
// Placeholder values would require a vault write. The whole call log is
|
|
58
|
+
// one `--version` probe plus one `list --json` per check — never `add`,
|
|
59
|
+
// `init`, `remove` or `sync` — and the vault file is byte-identical.
|
|
60
|
+
expect(onlyReadCalls()).toBe(true);
|
|
61
|
+
expect(harness.callsTo("list")).toHaveLength(4);
|
|
62
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
63
|
+
expect(vaultText()).toBe(before);
|
|
64
|
+
expect(JSON.parse(vaultText())).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("keeps a partial vault's present/missing split identical across repeated checks", async () => {
|
|
68
|
+
writeFileSync(harness.vaultPath, JSON.stringify({ SUPABASE_ANON_KEY: "x" }));
|
|
69
|
+
const missing = [
|
|
70
|
+
"supabase:SUPABASE_URL",
|
|
71
|
+
"supabase:SUPABASE_SERVICE_ROLE_KEY",
|
|
72
|
+
"openai:OPENAI_API_KEY",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < 3; i++) {
|
|
76
|
+
const result = await wirePhantomForRecipe(recipe("supabase", "openai"), { cwd: dir });
|
|
77
|
+
expect(result.present).toEqual(["supabase:SUPABASE_ANON_KEY"]);
|
|
78
|
+
expect(result.missing).toEqual(missing);
|
|
79
|
+
expect(result.skipped).toEqual([]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
expect(onlyReadCalls()).toBe(true);
|
|
83
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
84
|
+
expect(JSON.parse(vaultText())).toEqual({ SUPABASE_ANON_KEY: "x" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("moves a secret to present only after a real `phantom add`, never from re-checking", async () => {
|
|
88
|
+
for (let i = 0; i < 2; i++) {
|
|
89
|
+
const result = await wirePhantomForRecipe(recipe("stripe"), { cwd: dir });
|
|
90
|
+
expect(result.missing).toEqual(["stripe:STRIPE_SECRET_KEY"]);
|
|
91
|
+
expect(result.present).toEqual([]);
|
|
92
|
+
}
|
|
93
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
94
|
+
|
|
95
|
+
// A real write through Phantom is the only thing that may shrink
|
|
96
|
+
// `missing`; the check itself reports it faithfully afterwards.
|
|
97
|
+
await addSecret("STRIPE_SECRET_KEY", "user-supplied-real-value", dir);
|
|
98
|
+
const after = await wirePhantomForRecipe(recipe("stripe"), { cwd: dir });
|
|
99
|
+
expect(after.present).toEqual(["stripe:STRIPE_SECRET_KEY"]);
|
|
100
|
+
expect(after.missing).toEqual([]);
|
|
101
|
+
|
|
102
|
+
// Exactly one `add` in the log — the explicit write above, in create-only
|
|
103
|
+
// `--stdin` form. The checks contributed none.
|
|
104
|
+
expect(harness.callsTo("add")).toEqual([
|
|
105
|
+
{ cmd: "add", args: ["STRIPE_SECRET_KEY", "--stdin"] },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("leaves no placeholder artifacts on disk beyond the documented webhook stubs", async () => {
|
|
110
|
+
// `openai` has missing secrets and no webhook stub: a repeated check must
|
|
111
|
+
// not drop a placeholder file for it anywhere in the project tree.
|
|
112
|
+
for (let i = 0; i < 2; i++) {
|
|
113
|
+
await wirePhantomForRecipe(recipe("stripe", "openai", "github"), { cwd: dir });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
expect(readdirSync(dir)).toEqual([".stack"]);
|
|
117
|
+
expect(readdirSync(join(dir, ".stack"))).toEqual(["webhooks"]);
|
|
118
|
+
expect(readdirSync(join(dir, ".stack", "webhooks")).sort()).toEqual(["github.ts", "stripe.ts"]);
|
|
119
|
+
expect(existsSync(join(dir, ".env"))).toBe(false);
|
|
120
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
121
|
+
expect(JSON.parse(vaultText())).toEqual({});
|
|
122
|
+
});
|
|
123
|
+
});
|