@ashlr/stack-core 0.2.2 → 0.3.1
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/README.md +3 -3
- package/package.json +1 -1
- package/src/__tests__/agent-onboard.test.ts +62 -0
- package/src/__tests__/base-urls.test.ts +48 -2
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +23 -0
- package/src/__tests__/config-split.test.ts +66 -0
- package/src/__tests__/config.test.ts +92 -3
- package/src/__tests__/credential-staging.test.ts +3 -2
- package/src/__tests__/detect-source.test.ts +182 -3
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +38 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/phantom-harness.test.ts +102 -1
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-wire.test.ts +372 -2
- package/src/__tests__/pipeline-integration.test.ts +32 -0
- package/src/__tests__/publish-script.test.ts +63 -18
- package/src/__tests__/recipe.test.ts +23 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/template-litellm-gateway.test.ts +29 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
- package/src/ai/phantom-wire.ts +44 -6
- package/src/detect-source.ts +14 -1
- package/src/errors.ts +6 -0
- package/src/index.ts +1 -0
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +144 -33
- package/src/stack-manifest.ts +39 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type Recipe, readRecipe, writeRecipe } from "../ai/recipe.ts";
|
|
6
|
+
import { listSecrets } from "../phantom.ts";
|
|
6
7
|
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
7
8
|
|
|
9
|
+
// Every test spawns the fake `phantom` shim (bash + several python3 calls),
|
|
10
|
+
// which takes multiple seconds on loaded machines — well past the 5s default.
|
|
11
|
+
setDefaultTimeout(30_000);
|
|
12
|
+
|
|
8
13
|
describe("ai/phantom-wire", () => {
|
|
9
14
|
let harness: Harness;
|
|
10
15
|
|
|
@@ -41,6 +46,226 @@ describe("ai/phantom-wire", () => {
|
|
|
41
46
|
expect(result.webhooks).toHaveLength(1);
|
|
42
47
|
});
|
|
43
48
|
|
|
49
|
+
it("wires an altered local recipe without authenticating its contents", async () => {
|
|
50
|
+
const path = await writeRecipe(STRIPE_RECIPE, harness.dir);
|
|
51
|
+
const original = readFileSync(path, "utf-8");
|
|
52
|
+
expect(original).toContain("stripe");
|
|
53
|
+
writeFileSync(path, original.replace('stripe"', 'github"'));
|
|
54
|
+
|
|
55
|
+
const recipe = await readRecipe(STRIPE_RECIPE.id, harness.dir);
|
|
56
|
+
expect(recipe.providers).toEqual([{ name: "github" }]);
|
|
57
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
58
|
+
expect(result.missing).toContain("github:GITHUB_TOKEN");
|
|
59
|
+
expect(result.webhooks).toEqual([join(harness.dir, ".stack/webhooks/github.ts")]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("generates a commented request handler and response for every webhook provider", async () => {
|
|
63
|
+
const providers = ["stripe", "clerk", "supabase", "github"];
|
|
64
|
+
const recipe: Recipe = {
|
|
65
|
+
...STRIPE_RECIPE,
|
|
66
|
+
providers: providers.map((name) => ({ name })),
|
|
67
|
+
};
|
|
68
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
69
|
+
expect(result.webhooks).toEqual(
|
|
70
|
+
providers.map((provider) => join(harness.dir, ".stack/webhooks", `${provider}.ts`)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
for (const [index, provider] of providers.entries()) {
|
|
74
|
+
const stub = readFileSync(result.webhooks[index], "utf-8");
|
|
75
|
+
const handlerName = `handle${provider[0].toUpperCase()}${provider.slice(1)}Webhook`;
|
|
76
|
+
const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
|
|
77
|
+
expect(example).toBeDefined();
|
|
78
|
+
expect(example).toContain(
|
|
79
|
+
`// export async function ${handlerName}(req: Request): Promise<Response> {`,
|
|
80
|
+
);
|
|
81
|
+
expect(example).toContain('// return new Response("ok", { status: 200 });');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each([
|
|
86
|
+
["stripe", "handleStripeWebhook"],
|
|
87
|
+
["clerk", "handleClerkWebhook"],
|
|
88
|
+
["supabase", "handleSupabaseWebhook"],
|
|
89
|
+
["github", "handleGithubWebhook"],
|
|
90
|
+
])("returns the unsigned %s response without reading the body", async (provider, name) => {
|
|
91
|
+
const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
|
|
92
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
93
|
+
const stub = readFileSync(result.webhooks[0], "utf-8");
|
|
94
|
+
const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
|
|
95
|
+
if (!example) throw new Error(`Missing ${provider} webhook example`);
|
|
96
|
+
const examplePath = join(harness.dir, `${provider}-unsigned-example.ts`);
|
|
97
|
+
writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
|
|
98
|
+
const handler: unknown = (await import(examplePath))[name];
|
|
99
|
+
if (typeof handler !== "function") throw new Error(`Missing ${name}`);
|
|
100
|
+
|
|
101
|
+
const request = new Request("https://example.com", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: "untrusted webhook payload",
|
|
104
|
+
});
|
|
105
|
+
const response: Response = await handler(request);
|
|
106
|
+
expect(response.status).toBe(400);
|
|
107
|
+
expect(await response.text()).toBe("Missing signature");
|
|
108
|
+
expect(request.bodyUsed).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.each([
|
|
112
|
+
["stripe", "stripe-signature", "handleStripeWebhook"],
|
|
113
|
+
["clerk", "svix-signature", "handleClerkWebhook"],
|
|
114
|
+
["supabase", "x-supabase-signature", "handleSupabaseWebhook"],
|
|
115
|
+
["github", "x-hub-signature-256", "handleGithubWebhook"],
|
|
116
|
+
])("rejects missing signatures and interrupted %s requests", async (provider, header, name) => {
|
|
117
|
+
const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
|
|
118
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
119
|
+
expect(result.webhooks).toHaveLength(1);
|
|
120
|
+
|
|
121
|
+
const stub = readFileSync(result.webhooks[0], "utf-8");
|
|
122
|
+
expect(stub.split("\n").every((line) => line === "" || line.startsWith("//"))).toBe(true);
|
|
123
|
+
expect(stub).toContain("This entire example is commented out and does not verify signatures.");
|
|
124
|
+
expect(stub).toContain(
|
|
125
|
+
"If uncommented as-is, it accepts any non-empty signature header and returns\n" +
|
|
126
|
+
"// 200 without authenticating the request. Implement verification first.",
|
|
127
|
+
);
|
|
128
|
+
const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
|
|
129
|
+
if (!example) throw new Error(`Missing ${provider} webhook example`);
|
|
130
|
+
expect(example).toContain(
|
|
131
|
+
"// // TODO(security): signature verification is NOT implemented here. The\n" +
|
|
132
|
+
"// // header check above proves only that a signature header arrived; its\n" +
|
|
133
|
+
"// // value is never compared to anything.",
|
|
134
|
+
);
|
|
135
|
+
expect(example).toContain(
|
|
136
|
+
`// // is unauthenticated, attacker-controlled input — anyone who can POST\n// // to this endpoint can forge a ${provider} webhook.`,
|
|
137
|
+
);
|
|
138
|
+
expect(example).toContain(
|
|
139
|
+
"// // constant time; reject missing or invalid signatures, and only then\n" +
|
|
140
|
+
"// // parse, trust, or act on the body.",
|
|
141
|
+
);
|
|
142
|
+
const examplePath = join(harness.dir, `${provider}-example.ts`);
|
|
143
|
+
writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
|
|
144
|
+
const handler: unknown = (await import(examplePath))[name];
|
|
145
|
+
expect(typeof handler).toBe("function");
|
|
146
|
+
if (typeof handler !== "function") throw new Error(`Missing ${name}`);
|
|
147
|
+
|
|
148
|
+
const missingHeaders: HeadersInit[] = [
|
|
149
|
+
{},
|
|
150
|
+
{ "x-unrelated-signature": "present" },
|
|
151
|
+
{ [header]: "" },
|
|
152
|
+
];
|
|
153
|
+
for (const headers of missingHeaders) {
|
|
154
|
+
const response: Response = await handler(new Request("https://example.com", { headers }));
|
|
155
|
+
expect(response.status).toBe(400);
|
|
156
|
+
expect(await response.text()).toBe("Missing signature");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A non-empty header reaches the placeholder; it is not signature verification.
|
|
160
|
+
const response: Response = await handler(
|
|
161
|
+
new Request("https://example.com", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { [header]: "placeholder" },
|
|
164
|
+
body: "unverified webhook payload",
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
expect(response.status).toBe(200);
|
|
168
|
+
|
|
169
|
+
const networkError = new Error("request body connection lost");
|
|
170
|
+
const interruptedBody = new ReadableStream<Uint8Array>({
|
|
171
|
+
pull(controller) {
|
|
172
|
+
controller.error(networkError);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const interruptedRequest = new Request("https://example.com", {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { [header]: "placeholder" },
|
|
178
|
+
body: interruptedBody,
|
|
179
|
+
});
|
|
180
|
+
await expect(handler(interruptedRequest)).rejects.toBe(networkError);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it.each([
|
|
184
|
+
["stripe", "stripe-signature", "handleStripeWebhook"],
|
|
185
|
+
["clerk", "svix-signature", "handleClerkWebhook"],
|
|
186
|
+
["supabase", "x-supabase-signature", "handleSupabaseWebhook"],
|
|
187
|
+
["github", "x-hub-signature-256", "handleGithubWebhook"],
|
|
188
|
+
])(
|
|
189
|
+
"returns the successful %s webhook response after reading the body",
|
|
190
|
+
async (provider, header, name) => {
|
|
191
|
+
const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
|
|
192
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
193
|
+
const stub = readFileSync(result.webhooks[0], "utf-8");
|
|
194
|
+
const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
|
|
195
|
+
if (!example) throw new Error(`Missing ${provider} webhook example`);
|
|
196
|
+
const examplePath = join(harness.dir, `${provider}-success-example.ts`);
|
|
197
|
+
writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
|
|
198
|
+
const handler: unknown = (await import(examplePath))[name];
|
|
199
|
+
if (typeof handler !== "function") throw new Error(`Missing ${name}`);
|
|
200
|
+
|
|
201
|
+
const request = new Request("https://example.com", {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { [header]: "placeholder" },
|
|
204
|
+
body: "unverified webhook payload",
|
|
205
|
+
});
|
|
206
|
+
const response: Response = await handler(request);
|
|
207
|
+
expect(request.bodyUsed).toBe(true);
|
|
208
|
+
expect(response.status).toBe(200);
|
|
209
|
+
expect(await response.text()).toBe("ok");
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
it.each(["stripe", "clerk", "supabase", "github"])(
|
|
214
|
+
"places the signature verification TODO before trusting the %s webhook body",
|
|
215
|
+
async (provider) => {
|
|
216
|
+
const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
|
|
217
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
218
|
+
expect(result.webhooks).toHaveLength(1);
|
|
219
|
+
const stub = readFileSync(result.webhooks[0], "utf-8");
|
|
220
|
+
const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
|
|
221
|
+
if (!example) throw new Error(`Missing ${provider} webhook example`);
|
|
222
|
+
|
|
223
|
+
// Reading the raw body supplies an input to verification; no executable
|
|
224
|
+
// handling of it should appear before the verification reminder.
|
|
225
|
+
expect(example).toContain(
|
|
226
|
+
"// const body = await req.text();\n" +
|
|
227
|
+
"// // TODO(security): signature verification is NOT implemented here.",
|
|
228
|
+
);
|
|
229
|
+
const todo = example.indexOf("TODO(security): signature verification is NOT implemented");
|
|
230
|
+
const success = example.indexOf('return new Response("ok", { status: 200 })');
|
|
231
|
+
expect(todo).toBeGreaterThanOrEqual(0);
|
|
232
|
+
expect(success).toBeGreaterThan(todo);
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
it.each([
|
|
237
|
+
["stripe", "stripe.webhooks.constructEvent"],
|
|
238
|
+
["clerk", "new Webhook(secret).verify(body, headers)"],
|
|
239
|
+
["supabase", "`x-supabase-signature` per Supabase's documented webhook scheme"],
|
|
240
|
+
["github", "`sha256=` + HMAC-SHA256"],
|
|
241
|
+
])(
|
|
242
|
+
"points the %s stub at the provider's verification scheme without claiming it ran",
|
|
243
|
+
async (provider, hint) => {
|
|
244
|
+
const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
|
|
245
|
+
const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
|
|
246
|
+
const example = readFileSync(result.webhooks[0], "utf-8").match(
|
|
247
|
+
/^\/\/ export async function [\s\S]*?^\/\/ }$/m,
|
|
248
|
+
)?.[0];
|
|
249
|
+
if (!example) throw new Error(`Missing ${provider} webhook example`);
|
|
250
|
+
|
|
251
|
+
// The TODO names where verification belongs and how the provider does it.
|
|
252
|
+
expect(example).toContain(hint);
|
|
253
|
+
|
|
254
|
+
// No comment may describe the request as already verified, and the
|
|
255
|
+
// uncommented handler must contain no verification code — the TODO's
|
|
256
|
+
// "NOT implemented" claim has to be true, not aspirational.
|
|
257
|
+
expect(example).not.toMatch(
|
|
258
|
+
/signature verified|verified signature|request is authenticated/i,
|
|
259
|
+
);
|
|
260
|
+
const runnable = example
|
|
261
|
+
.replace(/^\/\/ ?/gm, "")
|
|
262
|
+
.split("\n")
|
|
263
|
+
.filter((line) => !line.trimStart().startsWith("//"))
|
|
264
|
+
.join("\n");
|
|
265
|
+
expect(runnable).not.toMatch(/createHmac|timingSafeEqual|\.verify\(/);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
44
269
|
it("noWire: true skips the vault check and webhooks entirely", async () => {
|
|
45
270
|
const result = await wirePhantomForRecipe(STRIPE_RECIPE, {
|
|
46
271
|
cwd: harness.dir,
|
|
@@ -82,6 +307,151 @@ describe("ai/phantom-wire", () => {
|
|
|
82
307
|
expect(harness.callsTo("add")).toEqual([]);
|
|
83
308
|
});
|
|
84
309
|
|
|
310
|
+
it("surfaces a failed inventory command as unavailable and skips providers", async () => {
|
|
311
|
+
const phantomPath = join(harness.dir, "phantom");
|
|
312
|
+
const shim = readFileSync(phantomPath, "utf-8");
|
|
313
|
+
// Even plausible inventory on stdout is not evidence when the command fails.
|
|
314
|
+
expect(shim).toContain(" list)\n");
|
|
315
|
+
writeFileSync(
|
|
316
|
+
phantomPath,
|
|
317
|
+
shim.replace(
|
|
318
|
+
" list)\n",
|
|
319
|
+
` list)\n echo '[{"name":"STRIPE_SECRET_KEY"}]'\n echo 'inventory unavailable' >&2\n exit 1\n`,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
324
|
+
code: "PHANTOM_METADATA_UNAVAILABLE",
|
|
325
|
+
message: "Phantom secret inventory is unavailable; stored names are unknown.",
|
|
326
|
+
});
|
|
327
|
+
const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: "stripe" }, { name: "neon" }] };
|
|
328
|
+
const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
|
|
329
|
+
expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual({
|
|
330
|
+
present: [],
|
|
331
|
+
missing: [],
|
|
332
|
+
webhooks: [stubPath],
|
|
333
|
+
skipped: ["stripe", "neon"],
|
|
334
|
+
});
|
|
335
|
+
expect(existsSync(stubPath)).toBe(true);
|
|
336
|
+
expect(harness.callsTo("list")).toEqual([
|
|
337
|
+
{ cmd: "list", args: ["--json"] },
|
|
338
|
+
{ cmd: "list", args: ["--json"] },
|
|
339
|
+
]);
|
|
340
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
341
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("treats surrounding whitespace in vault inventory JSON the same as compact JSON", async () => {
|
|
345
|
+
const inventory = '[{"name":"STRIPE_SECRET_KEY"}]';
|
|
346
|
+
const setListOutput = (output: string) => {
|
|
347
|
+
writeFileSync(
|
|
348
|
+
join(harness.dir, "phantom"),
|
|
349
|
+
`#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '${output}' ;;\n *) exit 1 ;;\nesac\n`,
|
|
350
|
+
);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
setListOutput(inventory);
|
|
354
|
+
const compact = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
355
|
+
expect(compact).toEqual({
|
|
356
|
+
present: ["stripe:STRIPE_SECRET_KEY"],
|
|
357
|
+
missing: [],
|
|
358
|
+
skipped: [],
|
|
359
|
+
webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
setListOutput(` \t\r\n${inventory}\n\t `);
|
|
363
|
+
expect(await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir })).toEqual(compact);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("rejects malformed vault inventory JSON and keeps the provider's secrets unknown", async () => {
|
|
367
|
+
// A successful command may still return a truncated inventory containing a plausible name.
|
|
368
|
+
writeFileSync(
|
|
369
|
+
join(harness.dir, "phantom"),
|
|
370
|
+
`#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '[{"name":"STRIPE_SECRET_KEY"}' ;;\n *) exit 1 ;;\nesac\n`,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
374
|
+
code: "PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
375
|
+
message:
|
|
376
|
+
"phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
|
|
377
|
+
});
|
|
378
|
+
const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
379
|
+
expect(result).toEqual({
|
|
380
|
+
present: [],
|
|
381
|
+
missing: [],
|
|
382
|
+
skipped: ["stripe"],
|
|
383
|
+
webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
|
|
384
|
+
});
|
|
385
|
+
expect(existsSync(result.webhooks[0])).toBe(true);
|
|
386
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("rejects a JSON number from the vault inventory and skips the provider", async () => {
|
|
390
|
+
// Return valid JSON from a successful list command, but not the required array.
|
|
391
|
+
writeFileSync(
|
|
392
|
+
join(harness.dir, "phantom"),
|
|
393
|
+
'#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) echo 42 ;;\n *) exit 1 ;;\nesac\n',
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
397
|
+
code: "PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
398
|
+
message: "phantom list --json did not return an array.",
|
|
399
|
+
});
|
|
400
|
+
const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
401
|
+
expect(result).toEqual({
|
|
402
|
+
present: [],
|
|
403
|
+
missing: [],
|
|
404
|
+
skipped: ["stripe"],
|
|
405
|
+
webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
|
|
406
|
+
});
|
|
407
|
+
expect(existsSync(result.webhooks[0])).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it.each(["true", "false"])(
|
|
411
|
+
"rejects JSON boolean %s from the vault inventory and skips the provider",
|
|
412
|
+
async (output) => {
|
|
413
|
+
writeFileSync(
|
|
414
|
+
join(harness.dir, "phantom"),
|
|
415
|
+
`#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) echo ${output} ;;\n *) exit 1 ;;\nesac\n`,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
419
|
+
code: "PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
420
|
+
message: "phantom list --json did not return an array.",
|
|
421
|
+
});
|
|
422
|
+
const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
423
|
+
expect(result).toEqual({
|
|
424
|
+
present: [],
|
|
425
|
+
missing: [],
|
|
426
|
+
skipped: ["stripe"],
|
|
427
|
+
webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
|
|
428
|
+
});
|
|
429
|
+
expect(existsSync(result.webhooks[0])).toBe(true);
|
|
430
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
it("does not report a JSON string vault response as a verified secret", async () => {
|
|
435
|
+
// A successful command returning the expected name as a scalar is not an inventory.
|
|
436
|
+
writeFileSync(
|
|
437
|
+
join(harness.dir, "phantom"),
|
|
438
|
+
`#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '"STRIPE_SECRET_KEY"' ;;\n *) exit 1 ;;\nesac\n`,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
442
|
+
code: "PHANTOM_OUTPUT_UNRECOGNIZED",
|
|
443
|
+
message: "phantom list --json did not return an array.",
|
|
444
|
+
});
|
|
445
|
+
const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
|
|
446
|
+
expect(result).toEqual({
|
|
447
|
+
present: [],
|
|
448
|
+
missing: [],
|
|
449
|
+
skipped: ["stripe"],
|
|
450
|
+
webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
|
|
451
|
+
});
|
|
452
|
+
expect(existsSync(result.webhooks[0])).toBe(true);
|
|
453
|
+
});
|
|
454
|
+
|
|
85
455
|
it("skips providers with no webhook stub without crashing", async () => {
|
|
86
456
|
const recipe: Recipe = {
|
|
87
457
|
id: "neon",
|
|
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
|
|
6
|
+
import { readRecipe, writeRecipe } from "../ai/recipe.ts";
|
|
5
7
|
import { emptyConfig, writeConfig } from "../config.ts";
|
|
6
8
|
import { addService } from "../pipeline.ts";
|
|
7
9
|
import type { Provider } from "../providers/_base.ts";
|
|
@@ -93,3 +95,33 @@ describe("addService full loop", () => {
|
|
|
93
95
|
Reflect.deleteProperty(providers, "stubsvc");
|
|
94
96
|
});
|
|
95
97
|
});
|
|
98
|
+
|
|
99
|
+
test("apply wiring preserves an existing value and leaves missing values absent", async () => {
|
|
100
|
+
const initialVault = {
|
|
101
|
+
NEON_API_KEY: "existing-test-value",
|
|
102
|
+
UNRELATED_KEY: "unrelated-test-value",
|
|
103
|
+
};
|
|
104
|
+
const h = setupFakePhantom(initialVault);
|
|
105
|
+
try {
|
|
106
|
+
const recipe = {
|
|
107
|
+
id: "preserve-existing-value",
|
|
108
|
+
query: "database",
|
|
109
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
110
|
+
providers: [{ name: "neon" }],
|
|
111
|
+
};
|
|
112
|
+
await writeRecipe(recipe, h.dir);
|
|
113
|
+
const loaded = await readRecipe(recipe.id, h.dir);
|
|
114
|
+
|
|
115
|
+
// This is the vault-wiring phase used by stack apply after provisioning.
|
|
116
|
+
const result = await wirePhantomForRecipe(loaded, { cwd: h.dir });
|
|
117
|
+
expect(result.present).toEqual(["neon:NEON_API_KEY"]);
|
|
118
|
+
expect(result.missing).toEqual(["neon:DATABASE_URL"]);
|
|
119
|
+
expect(result.skipped).toEqual([]);
|
|
120
|
+
expect(h.callsTo("list")).toEqual([{ cmd: "list", args: ["--json"] }]);
|
|
121
|
+
expect(h.callsTo("add")).toEqual([]);
|
|
122
|
+
expect(h.callsTo("remove")).toEqual([]);
|
|
123
|
+
expect(await readVault(h.dir)).toEqual(initialVault);
|
|
124
|
+
} finally {
|
|
125
|
+
h.cleanup();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
@@ -151,7 +151,6 @@ describe("publish script early parser (copied script, inert commands)", () => {
|
|
|
151
151
|
join(bin, "node-reader.cjs"),
|
|
152
152
|
`const {readFileSync, appendFileSync} = require('node:fs');
|
|
153
153
|
const {createHash} = require('node:crypto');
|
|
154
|
-
const {spawnSync} = require('node:child_process');
|
|
155
154
|
const input = readFileSync(0);
|
|
156
155
|
const args = process.argv.slice(2);
|
|
157
156
|
if (args.length !== 3 || args[0] !== '--input-type=module' || args[1] !== '-'
|
|
@@ -159,12 +158,14 @@ if (args.length !== 3 || args[0] !== '--input-type=module' || args[1] !== '-'
|
|
|
159
158
|
appendFileSync(process.env.SCRIPT_FIXTURE_TRACE, 'node\\n'); process.exit(97);
|
|
160
159
|
}
|
|
161
160
|
appendFileSync(process.env.VALIDATOR_TRACE, 'read\\n');
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
// Run the hash-checked module in this Node process: a second Node startup
|
|
162
|
+
// can exhaust the outer 1500ms fixture bound under coverage instrumentation.
|
|
163
|
+
process.argv = [process.execPath, '-', args[2]];
|
|
164
|
+
const timer = setTimeout(() => process.exit(98), 1000);
|
|
165
|
+
import('data:text/javascript;base64,' + input.toString('base64')).then(
|
|
166
|
+
() => clearTimeout(timer),
|
|
167
|
+
() => process.exit(98),
|
|
168
|
+
);
|
|
168
169
|
`,
|
|
169
170
|
{ mode: 0o700 },
|
|
170
171
|
);
|
|
@@ -398,6 +399,37 @@ process.exit(result.status);
|
|
|
398
399
|
).toBeUndefined();
|
|
399
400
|
}
|
|
400
401
|
});
|
|
402
|
+
for (const alteration of ["source", "arguments"] as const) {
|
|
403
|
+
test(`validator wrapper refuses altered ${alteration} before evaluation`, async () => {
|
|
404
|
+
removeFixtureBackups();
|
|
405
|
+
const original = readFileSync(script, "utf8");
|
|
406
|
+
const altered =
|
|
407
|
+
alteration === "source"
|
|
408
|
+
? original.replace(
|
|
409
|
+
"import { lstatSync, readFileSync }",
|
|
410
|
+
"/* changed */ import { lstatSync, readFileSync }",
|
|
411
|
+
)
|
|
412
|
+
: original.replace(
|
|
413
|
+
'node --input-type=module - "$VERSION"',
|
|
414
|
+
'node --input-type=module - "$VERSION" extra',
|
|
415
|
+
);
|
|
416
|
+
expect(altered).not.toBe(original);
|
|
417
|
+
writeFileSync(script, altered);
|
|
418
|
+
const result = await runBackupCase();
|
|
419
|
+
expect(result.status).toBe(1);
|
|
420
|
+
expect(result.output).toContain("source version validation failed");
|
|
421
|
+
expect(readFileSync(trace, "utf8")).toBe("node\n");
|
|
422
|
+
expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("");
|
|
423
|
+
for (const name of ["core", "mcp", "cli"]) {
|
|
424
|
+
expect(
|
|
425
|
+
lstatSync(join(root, "packages", name, "package.json.bak"), {
|
|
426
|
+
throwIfNoEntry: false,
|
|
427
|
+
}),
|
|
428
|
+
).toBeUndefined();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
401
433
|
function inputs() {
|
|
402
434
|
return [
|
|
403
435
|
...preserved.map((file) => file.path),
|
|
@@ -570,14 +602,17 @@ fi
|
|
|
570
602
|
|
|
571
603
|
async function preflight(
|
|
572
604
|
name: string,
|
|
573
|
-
|
|
605
|
+
tag: string,
|
|
574
606
|
otp: string,
|
|
575
|
-
ref = "refs/heads/
|
|
607
|
+
ref = "refs/heads/main",
|
|
608
|
+
event = "workflow_dispatch",
|
|
576
609
|
) {
|
|
577
610
|
const block = workflowBlock(name);
|
|
578
|
-
|
|
611
|
+
// Workflow-level RELEASE_TAG: the pushed tag, or the dispatch tag input.
|
|
612
|
+
env.RELEASE_TAG = tag;
|
|
579
613
|
env.RELEASE_INPUT_OTP = otp;
|
|
580
614
|
env.GITHUB_REF = ref;
|
|
615
|
+
env.GITHUB_EVENT_NAME = event;
|
|
581
616
|
env.GITHUB_OUTPUT = join(root, "github-output");
|
|
582
617
|
writeFileSync(env.GITHUB_OUTPUT, "", { mode: 0o600 });
|
|
583
618
|
const before = inputs();
|
|
@@ -597,7 +632,7 @@ fi
|
|
|
597
632
|
]) {
|
|
598
633
|
for (const otp of ["synthetic-otp-secret", " ", "$(synthetic-otp-secret)"]) {
|
|
599
634
|
test(`${name} refuses nonempty unsupported OTP before subprocesses ${JSON.stringify(otp)}`, async () => {
|
|
600
|
-
const result = await preflight(name, "
|
|
635
|
+
const result = await preflight(name, "v0.2.1", otp);
|
|
601
636
|
expect(result.status).toBe(1);
|
|
602
637
|
expect(result.output).toContain("release input/source validation failed");
|
|
603
638
|
expect(result.output).not.toContain(otp.trim() || "OTP");
|
|
@@ -605,25 +640,35 @@ fi
|
|
|
605
640
|
expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
|
|
606
641
|
});
|
|
607
642
|
}
|
|
608
|
-
test(`${name}
|
|
609
|
-
|
|
643
|
+
test(`${name} validates dispatched tag source and exports only its version`, async () => {
|
|
644
|
+
cannedGit("same", "same");
|
|
645
|
+
const result = await preflight(name, "v0.2.1", "");
|
|
610
646
|
expect(result.status).toBe(0);
|
|
611
647
|
expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
|
|
612
648
|
expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("version=0.2.1\n");
|
|
613
649
|
});
|
|
614
|
-
test(`${name} refuses absent, malformed and mismatched
|
|
615
|
-
|
|
616
|
-
|
|
650
|
+
test(`${name} refuses absent, malformed and mismatched dispatch tags`, async () => {
|
|
651
|
+
cannedGit("same", "same");
|
|
652
|
+
for (const tag of ["", "vsynthetic-version-secret", "v0.2.2"]) {
|
|
653
|
+
const result = await preflight(name, tag, "");
|
|
617
654
|
expect(result.status).toBe(1);
|
|
618
655
|
expect(result.output).not.toContain("synthetic-version-secret");
|
|
619
656
|
expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
|
|
620
657
|
}
|
|
621
658
|
});
|
|
659
|
+
test(`${name} refuses a dispatched tag that is not the checked-out HEAD`, async () => {
|
|
660
|
+
cannedGit("tag", "head");
|
|
661
|
+
const result = await preflight(name, "v0.2.1", "");
|
|
662
|
+
expect(result.status).toBe(1);
|
|
663
|
+
// Source validation ran, so the refusal is the tag/HEAD check.
|
|
664
|
+
expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
|
|
665
|
+
expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
|
|
666
|
+
});
|
|
622
667
|
test(`${name} validates actual selected-tag coherence`, async () => {
|
|
623
668
|
cannedGit("same", "same");
|
|
624
|
-
const matching = await preflight(name, "
|
|
669
|
+
const matching = await preflight(name, "v0.2.1", "", "refs/tags/v0.2.1", "push");
|
|
625
670
|
expect(matching.status).toBe(0);
|
|
626
|
-
const mismatch = await preflight(name, "
|
|
671
|
+
const mismatch = await preflight(name, "v0.2.2", "", "refs/tags/v0.2.1", "push");
|
|
627
672
|
expect(mismatch.status).toBe(1);
|
|
628
673
|
expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
|
|
629
674
|
});
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { parseRecipeDraft } from "../ai/inference.ts";
|
|
5
6
|
import {
|
|
6
7
|
type Recipe,
|
|
7
8
|
listRecipes,
|
|
@@ -20,6 +21,28 @@ function scratchDir(): { dir: string; cleanup: () => void } {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
describe("ai/recipe", () => {
|
|
24
|
+
it("rejects a draft with no provider steps", () => {
|
|
25
|
+
expect(() => parseRecipeDraft('{"providers":[]}')).toThrow(
|
|
26
|
+
"Inference response contained no usable providers",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("round-trips a saved recipe with no provider steps", async () => {
|
|
31
|
+
const { dir, cleanup } = scratchDir();
|
|
32
|
+
try {
|
|
33
|
+
const recipe: Recipe = {
|
|
34
|
+
id: "empty",
|
|
35
|
+
query: "no providers",
|
|
36
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
37
|
+
providers: [],
|
|
38
|
+
};
|
|
39
|
+
await writeRecipe(recipe, dir);
|
|
40
|
+
expect(await readRecipe(recipe.id, dir)).toEqual(recipe);
|
|
41
|
+
} finally {
|
|
42
|
+
cleanup();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
23
46
|
it("round-trips a Recipe through write → read with fields preserved", async () => {
|
|
24
47
|
const { dir, cleanup } = scratchDir();
|
|
25
48
|
try {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* hitting the user's actual vault. We assert on its call log.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
22
22
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { tmpdir } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
} from "../ai/recipe.ts";
|
|
34
34
|
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
35
35
|
|
|
36
|
+
// `wirePhantomForRecipe` spawns the fake `phantom` shim (bash + several
|
|
37
|
+
// python3 calls), which takes multiple seconds on loaded machines.
|
|
38
|
+
setDefaultTimeout(30_000);
|
|
39
|
+
|
|
36
40
|
describe("recommend → apply e2e", () => {
|
|
37
41
|
let harness: Harness;
|
|
38
42
|
// A project-scratch cwd, separate from the phantom harness's own dir, so
|
|
@@ -101,6 +105,17 @@ describe("recommend → apply e2e", () => {
|
|
|
101
105
|
expect(harness.callsTo("add")).toEqual([]);
|
|
102
106
|
const vault = JSON.parse(readFileSync(harness.vaultPath, "utf-8")) as Record<string, string>;
|
|
103
107
|
expect(vault).toEqual({ NEON_API_KEY: "neon_real" });
|
|
108
|
+
|
|
109
|
+
// Simulate a user adding one missing value, then check again.
|
|
110
|
+
const updatedVault = { ...vault, OPENAI_API_KEY: "openai_real" };
|
|
111
|
+
writeFileSync(harness.vaultPath, JSON.stringify(updatedVault));
|
|
112
|
+
const rechecked = await wirePhantomForRecipe(loaded, { cwd: projectDir });
|
|
113
|
+
expect(rechecked.present).toEqual(["neon:NEON_API_KEY", "openai:OPENAI_API_KEY"]);
|
|
114
|
+
expect(rechecked.missing).toEqual(
|
|
115
|
+
result.missing.filter((name) => name !== "openai:OPENAI_API_KEY"),
|
|
116
|
+
);
|
|
117
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
118
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual(updatedVault);
|
|
104
119
|
});
|
|
105
120
|
|
|
106
121
|
it("noWire: true performs zero phantom vault calls and writes no webhook stubs", async () => {
|