@ashlr/stack-core 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { LogEvent } from "../providers/_base.ts";
|
|
3
|
+
import { scrub } from "../providers/_helpers.ts";
|
|
4
|
+
import { makeApiKeyProvider } from "../providers/_api-key.ts";
|
|
5
|
+
import { StackError } from "../errors.ts";
|
|
6
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Secret redaction regression pack.
|
|
10
|
+
*
|
|
11
|
+
* The biggest exfiltration risk for Stack is a log line that interpolates a
|
|
12
|
+
* raw token — tokens end up in CI artifacts, shell scrollback, sentry
|
|
13
|
+
* breadcrumbs, etc. These tests pin the contract: when an API-key provider
|
|
14
|
+
* discovers a cached token is no longer valid and logs a warning, the raw
|
|
15
|
+
* token MUST NOT appear in the log message.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
describe("scrub()", () => {
|
|
19
|
+
test("keeps the last N chars and masks the rest", () => {
|
|
20
|
+
expect(scrub("sk-abcdef1234567890")).toBe("****7890");
|
|
21
|
+
expect(scrub("sk-abcdef1234567890", 6)).toBe("****567890");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("short strings are fully redacted", () => {
|
|
25
|
+
expect(scrub("abcd")).toBe("****");
|
|
26
|
+
expect(scrub("abc")).toBe("****");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("empty string returns empty", () => {
|
|
30
|
+
expect(scrub("")).toBe("");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("api-key provider login — invalid cached token warning", () => {
|
|
35
|
+
let h: Harness;
|
|
36
|
+
let realFetch: typeof fetch;
|
|
37
|
+
const RAW_TOKEN = "sk-SECRET-ABCD-1234-SHOULD-NOT-LEAK-XYZ";
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
// Seed the vault so the provider's login() finds a cached token to reject.
|
|
41
|
+
h = setupFakePhantom({ TEST_PROVIDER_KEY: RAW_TOKEN });
|
|
42
|
+
realFetch = globalThis.fetch;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
globalThis.fetch = realFetch;
|
|
47
|
+
h.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("log lines never contain the raw cached token", async () => {
|
|
51
|
+
const logs: LogEvent[] = [];
|
|
52
|
+
|
|
53
|
+
const provider = makeApiKeyProvider({
|
|
54
|
+
name: "testprov",
|
|
55
|
+
displayName: "Test Provider",
|
|
56
|
+
category: "ai",
|
|
57
|
+
docs: "https://example.com",
|
|
58
|
+
secretName: "TEST_PROVIDER_KEY",
|
|
59
|
+
howTo: "n/a",
|
|
60
|
+
async verify(_key) {
|
|
61
|
+
// Simulate server rejection to trigger the "cached invalid" branch.
|
|
62
|
+
return undefined;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
provider.login({
|
|
68
|
+
cwd: process.cwd(),
|
|
69
|
+
interactive: false, // forces the no-prompt failure path
|
|
70
|
+
log: (event) => logs.push(event),
|
|
71
|
+
}),
|
|
72
|
+
).rejects.toThrow(StackError);
|
|
73
|
+
|
|
74
|
+
// The provider emitted at least one warn log about the cached token.
|
|
75
|
+
const warn = logs.find((l) => l.level === "warn");
|
|
76
|
+
expect(warn).toBeDefined();
|
|
77
|
+
// The raw token must not appear anywhere in the message or data payload.
|
|
78
|
+
const full =
|
|
79
|
+
(warn?.msg ?? "") + " " + JSON.stringify(warn?.data ?? {});
|
|
80
|
+
expect(full).not.toContain(RAW_TOKEN);
|
|
81
|
+
// And the tail of the token (last 6 chars) must not leak either — the
|
|
82
|
+
// current _api-key warn doesn't reference the token at all, so belt + suspenders.
|
|
83
|
+
expect(full).not.toContain(RAW_TOKEN.slice(-6));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("phantom error formatting — regression guard for formatArgsForError", () => {
|
|
88
|
+
/**
|
|
89
|
+
* Hand-roll a tiny phantom replacement that exits 1 on every call, so we
|
|
90
|
+
* can observe how `exec()` stringifies the failed-args list into its
|
|
91
|
+
* StackError. The full fake harness succeeds on `add`, which wouldn't
|
|
92
|
+
* exercise the error path.
|
|
93
|
+
*/
|
|
94
|
+
function setupAlwaysFailingPhantom(): { cleanup(): void } {
|
|
95
|
+
const { chmodSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs") as typeof import("node:fs");
|
|
96
|
+
const { tmpdir } = require("node:os") as typeof import("node:os");
|
|
97
|
+
const { join } = require("node:path") as typeof import("node:path");
|
|
98
|
+
const { __resetPhantomCache } = require("../phantom.ts") as typeof import("../phantom.ts");
|
|
99
|
+
|
|
100
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-phantom-fail-"));
|
|
101
|
+
const script = `#!/usr/bin/env bash
|
|
102
|
+
case "\${1:-}" in
|
|
103
|
+
--version) echo "phantom fake 0.0.1" ;;
|
|
104
|
+
*) echo "boom" >&2; exit 1 ;;
|
|
105
|
+
esac
|
|
106
|
+
`;
|
|
107
|
+
const binPath = join(dir, "phantom");
|
|
108
|
+
writeFileSync(binPath, script);
|
|
109
|
+
chmodSync(binPath, 0o755);
|
|
110
|
+
|
|
111
|
+
const previousPath = process.env.PATH ?? "";
|
|
112
|
+
process.env.PATH = `${dir}:${previousPath}`;
|
|
113
|
+
__resetPhantomCache();
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
cleanup() {
|
|
117
|
+
process.env.PATH = previousPath;
|
|
118
|
+
__resetPhantomCache();
|
|
119
|
+
try {
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
} catch {
|
|
122
|
+
/* best-effort */
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test("failed `phantom add <KEY> <VALUE>` never leaks the VALUE", async () => {
|
|
129
|
+
const SECRET = "super-secret-VALUE-that-MUST-not-LEAK-999";
|
|
130
|
+
const h = setupAlwaysFailingPhantom();
|
|
131
|
+
try {
|
|
132
|
+
const { exec } = await import("../phantom.ts");
|
|
133
|
+
let caught: Error | undefined;
|
|
134
|
+
try {
|
|
135
|
+
await exec(["add", "LEAKY_KEY", SECRET]);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
caught = err as Error;
|
|
138
|
+
}
|
|
139
|
+
expect(caught).toBeDefined();
|
|
140
|
+
// The canonical guarantee: the raw value NEVER appears in the error.
|
|
141
|
+
expect(caught!.message).not.toContain(SECRET);
|
|
142
|
+
// The key name is safe to show — it's the vault slot, not the value.
|
|
143
|
+
expect(caught!.message).toContain("LEAKY_KEY");
|
|
144
|
+
// And the redaction sentinel should be present.
|
|
145
|
+
expect(caught!.message).toContain("<redacted>");
|
|
146
|
+
} finally {
|
|
147
|
+
h.cleanup();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("reveal / list / status calls pass their args through unredacted (no secret in args)", async () => {
|
|
152
|
+
const h = setupAlwaysFailingPhantom();
|
|
153
|
+
try {
|
|
154
|
+
const { exec } = await import("../phantom.ts");
|
|
155
|
+
let caught: Error | undefined;
|
|
156
|
+
try {
|
|
157
|
+
await exec(["reveal", "SOME_KEY"]);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
caught = err as Error;
|
|
160
|
+
}
|
|
161
|
+
expect(caught).toBeDefined();
|
|
162
|
+
// Reveal's second arg is a key name, not a secret value — it's fine
|
|
163
|
+
// for it to appear in error output. The point is that `add`'s third arg
|
|
164
|
+
// is redacted, and reveal's isn't wrongly redacted into uselessness.
|
|
165
|
+
expect(caught!.message).toContain("SOME_KEY");
|
|
166
|
+
} finally {
|
|
167
|
+
h.cleanup();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { addSecret, exec, revealSecret } from "../phantom.ts";
|
|
3
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression tests for the Wave 9 security review findings. Each test here
|
|
7
|
+
* guards against a specific historical vulnerability — breaking any of these
|
|
8
|
+
* should be treated as a gating regression.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe("security — secret redaction in error messages", () => {
|
|
12
|
+
let h: Harness;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
h = setupFakePhantom();
|
|
15
|
+
// Put a fake `phantom` on PATH that always fails on `add`, so we can
|
|
16
|
+
// trigger the error path that used to leak secret values.
|
|
17
|
+
Bun.write(
|
|
18
|
+
`${h.dir}/phantom`,
|
|
19
|
+
`#!/usr/bin/env bash
|
|
20
|
+
case "$1" in
|
|
21
|
+
--version) echo "phantom fake 0.0.1" ;;
|
|
22
|
+
add) echo "simulated failure" >&2; exit 7 ;;
|
|
23
|
+
*) echo "ok" ;;
|
|
24
|
+
esac
|
|
25
|
+
`,
|
|
26
|
+
).then(() => Bun.spawn(["chmod", "+x", `${h.dir}/phantom`]).exited);
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => h.cleanup());
|
|
29
|
+
|
|
30
|
+
test("error thrown by `phantom add <K> <V>` does not contain the secret value", async () => {
|
|
31
|
+
// Give it a moment for the chmod/write to settle.
|
|
32
|
+
await Bun.spawn(["chmod", "+x", `${h.dir}/phantom`]).exited;
|
|
33
|
+
let caught: Error | undefined;
|
|
34
|
+
try {
|
|
35
|
+
await exec(["add", "MY_KEY", "super-secret-value-XYZ123"]);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
caught = err as Error;
|
|
38
|
+
}
|
|
39
|
+
expect(caught).toBeDefined();
|
|
40
|
+
expect(caught!.message).not.toContain("super-secret-value-XYZ123");
|
|
41
|
+
expect(caught!.message).toContain("<redacted>");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("security — fake phantom harness handles hostile secret values", () => {
|
|
46
|
+
let h: Harness;
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
h = setupFakePhantom();
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => h.cleanup());
|
|
51
|
+
|
|
52
|
+
test("a secret containing single quotes does not break the harness", async () => {
|
|
53
|
+
const hostile = `value-with-'-quote-and-\"-and-$VAR-and-\\backslash`;
|
|
54
|
+
await addSecret("HOSTILE_KEY", hostile);
|
|
55
|
+
const revealed = await revealSecret("HOSTILE_KEY");
|
|
56
|
+
expect(revealed).toBe(hostile);
|
|
57
|
+
const vault = await readVault(h.dir);
|
|
58
|
+
expect(vault.HOSTILE_KEY).toBe(hostile);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a secret containing a newline survives round-trip", async () => {
|
|
62
|
+
const hostile = "first-line\n-second-line";
|
|
63
|
+
await addSecret("MULTILINE_KEY", hostile);
|
|
64
|
+
// revealSecret trims trailing whitespace via stdout.trim() — that's fine,
|
|
65
|
+
// but round-tripping the full value via the vault file should preserve it.
|
|
66
|
+
const vault = await readVault(h.dir);
|
|
67
|
+
expect(vault.MULTILINE_KEY).toBe(hostile);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a secret that looks like a Python expression is stored literally", async () => {
|
|
71
|
+
const hostile = `'; import os; os.system('echo pwned > /tmp/STACK_PWNED'); '`;
|
|
72
|
+
await addSecret("PYTHON_INJECTION_KEY", hostile);
|
|
73
|
+
const vault = await readVault(h.dir);
|
|
74
|
+
expect(vault.PYTHON_INJECTION_KEY).toBe(hostile);
|
|
75
|
+
// Sanity: the marker file must NOT exist — if the injection had fired,
|
|
76
|
+
// this file would have been created by /tmp/STACK_PWNED.
|
|
77
|
+
const { existsSync } = await import("node:fs");
|
|
78
|
+
expect(existsSync("/tmp/STACK_PWNED")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import supabase from "../providers/supabase.ts";
|
|
3
|
+
import type { ProviderContext } from "../providers/_base.ts";
|
|
4
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Supabase provider — exercised end-to-end with fetch mocked. Validates that
|
|
8
|
+
* we send the right payloads to the Supabase Management API, parse the
|
|
9
|
+
* responses correctly, and route secrets into the fake vault.
|
|
10
|
+
*/
|
|
11
|
+
describe("supabase provider (mocked fetch)", () => {
|
|
12
|
+
let h: Harness;
|
|
13
|
+
let realFetch: typeof fetch;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
h = setupFakePhantom({
|
|
17
|
+
STACK_SUPABASE_ACCESS_TOKEN: "sbp_cached_valid_token",
|
|
18
|
+
});
|
|
19
|
+
realFetch = globalThis.fetch;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
globalThis.fetch = realFetch;
|
|
24
|
+
h.cleanup();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("reuses a cached token when the identity call succeeds", async () => {
|
|
28
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
29
|
+
const u = String(url);
|
|
30
|
+
if (u.endsWith("/v1/profile")) {
|
|
31
|
+
return new Response(JSON.stringify({ id: "u1", email: "mason@example.com" }), {
|
|
32
|
+
status: 200,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`unexpected fetch ${u}`);
|
|
36
|
+
}) as typeof fetch;
|
|
37
|
+
|
|
38
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
39
|
+
const auth = await supabase.login(ctx);
|
|
40
|
+
expect(auth.token).toBe("sbp_cached_valid_token");
|
|
41
|
+
expect(auth.identity?.email).toBe("mason@example.com");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("provision → materialize writes URL + anon + service_role", async () => {
|
|
45
|
+
const createdProject = {
|
|
46
|
+
id: "abcdefghij",
|
|
47
|
+
name: "stack-xyz",
|
|
48
|
+
region: "us-east-1",
|
|
49
|
+
organization_id: "org_1",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
53
|
+
const u = String(url);
|
|
54
|
+
const method = init?.method ?? "GET";
|
|
55
|
+
if (u.endsWith("/v1/organizations") && method === "GET") {
|
|
56
|
+
return new Response(JSON.stringify([{ id: "org_1", name: "Default" }]), { status: 200 });
|
|
57
|
+
}
|
|
58
|
+
if (u.endsWith("/v1/projects") && method === "POST") {
|
|
59
|
+
return new Response(JSON.stringify(createdProject), { status: 201 });
|
|
60
|
+
}
|
|
61
|
+
if (u.endsWith(`/v1/projects/${createdProject.id}/api-keys`)) {
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify([
|
|
64
|
+
{ name: "anon", api_key: "anon-key" },
|
|
65
|
+
{ name: "service_role", api_key: "service-key" },
|
|
66
|
+
]),
|
|
67
|
+
{ status: 200 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`unexpected fetch ${method} ${u}`);
|
|
71
|
+
}) as typeof fetch;
|
|
72
|
+
|
|
73
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
74
|
+
const auth = { token: "sbp_token", identity: {} };
|
|
75
|
+
const resource = await supabase.provision(ctx, auth, {});
|
|
76
|
+
expect(resource.id).toBe(createdProject.id);
|
|
77
|
+
expect(resource.region).toBe("us-east-1");
|
|
78
|
+
|
|
79
|
+
const materialized = await supabase.materialize(ctx, resource, auth);
|
|
80
|
+
expect(materialized.secrets.SUPABASE_URL).toBe(`https://${createdProject.id}.supabase.co`);
|
|
81
|
+
expect(materialized.secrets.SUPABASE_ANON_KEY).toBe("anon-key");
|
|
82
|
+
expect(materialized.secrets.SUPABASE_SERVICE_ROLE_KEY).toBe("service-key");
|
|
83
|
+
expect(materialized.mcp?.name).toBe("supabase");
|
|
84
|
+
|
|
85
|
+
// Provision also stashes the auto-generated DB password in Phantom.
|
|
86
|
+
const vault = await readVault(h.dir);
|
|
87
|
+
const dbPassKey = Object.keys(vault).find((k) => k.startsWith("SUPABASE_DB_PASSWORD_"));
|
|
88
|
+
expect(dbPassKey).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("bubbles up a useful error when the Management API rejects create", async () => {
|
|
92
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
93
|
+
const u = String(url);
|
|
94
|
+
if (u.endsWith("/v1/organizations")) {
|
|
95
|
+
return new Response(JSON.stringify([{ id: "o", name: "x" }]), { status: 200 });
|
|
96
|
+
}
|
|
97
|
+
if (u.endsWith("/v1/projects")) {
|
|
98
|
+
return new Response("rate limited", { status: 429 });
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`unexpected fetch ${u}`);
|
|
101
|
+
}) as typeof fetch;
|
|
102
|
+
|
|
103
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
104
|
+
await expect(supabase.provision(ctx, { token: "t" }, {})).rejects.toThrow(
|
|
105
|
+
/SUPABASE_CREATE_FAILED|project creation failed/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { listTemplates, loadTemplate } from "../templates.ts";
|
|
3
|
+
import { listProviderNames } from "../providers/index.ts";
|
|
4
|
+
|
|
5
|
+
describe("starter templates", () => {
|
|
6
|
+
test("all templates reference only registered providers", async () => {
|
|
7
|
+
const providerNames = new Set(listProviderNames());
|
|
8
|
+
const templates = listTemplates();
|
|
9
|
+
expect(templates.length).toBeGreaterThanOrEqual(4);
|
|
10
|
+
for (const name of templates) {
|
|
11
|
+
const template = await loadTemplate(name);
|
|
12
|
+
expect(template).toBeDefined();
|
|
13
|
+
for (const [serviceName, spec] of Object.entries(template?.services ?? {})) {
|
|
14
|
+
const providerName = spec.provider ?? serviceName;
|
|
15
|
+
expect(providerNames.has(providerName)).toBe(true);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("every template declares at least one environment", async () => {
|
|
21
|
+
for (const name of listTemplates()) {
|
|
22
|
+
const template = await loadTemplate(name);
|
|
23
|
+
expect(template?.environments?.length ?? 0).toBeGreaterThanOrEqual(1);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import turso from "../providers/turso.ts";
|
|
3
|
+
import type { ProviderContext } from "../providers/_base.ts";
|
|
4
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
5
|
+
|
|
6
|
+
describe("turso provider (mocked fetch)", () => {
|
|
7
|
+
let h: Harness;
|
|
8
|
+
let realFetch: typeof fetch;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
h = setupFakePhantom();
|
|
12
|
+
realFetch = globalThis.fetch;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
globalThis.fetch = realFetch;
|
|
17
|
+
h.cleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("provision creates a database and returns org/dbname resource id", async () => {
|
|
21
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
22
|
+
const u = String(url);
|
|
23
|
+
const method = init?.method ?? "GET";
|
|
24
|
+
if (u.endsWith("/organizations") && method === "GET") {
|
|
25
|
+
return new Response(
|
|
26
|
+
JSON.stringify([{ slug: "my-org", name: "My Org" }]),
|
|
27
|
+
{ status: 200 },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (u.endsWith("/organizations/my-org/databases") && method === "POST") {
|
|
31
|
+
return new Response(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
database: { Name: "stack-xyz", Hostname: "stack-xyz-my-org.turso.io" },
|
|
34
|
+
}),
|
|
35
|
+
{ status: 201 },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`unexpected ${method} ${u}`);
|
|
39
|
+
}) as unknown as typeof fetch;
|
|
40
|
+
|
|
41
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
42
|
+
const auth = { token: "turso-fake-platform-token", identity: {} };
|
|
43
|
+
const resource = await turso.provision(ctx, auth, {});
|
|
44
|
+
expect(resource.id).toBe("my-org/stack-xyz");
|
|
45
|
+
expect((resource.meta as { hostname?: string })?.hostname).toBe("stack-xyz-my-org.turso.io");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("materialize mints an auth token and writes TURSO_DATABASE_URL", async () => {
|
|
49
|
+
const jwt = "eyJfake_jwt";
|
|
50
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
51
|
+
const u = String(url);
|
|
52
|
+
const method = init?.method ?? "GET";
|
|
53
|
+
if (u.endsWith("/auth/tokens") && method === "POST") {
|
|
54
|
+
return new Response(JSON.stringify({ jwt }), { status: 200 });
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`unexpected ${method} ${u}`);
|
|
57
|
+
}) as unknown as typeof fetch;
|
|
58
|
+
|
|
59
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
60
|
+
const resource = {
|
|
61
|
+
id: "my-org/stack-xyz",
|
|
62
|
+
displayName: "stack-xyz",
|
|
63
|
+
meta: { hostname: "stack-xyz-my-org.turso.io" },
|
|
64
|
+
};
|
|
65
|
+
const auth = { token: "turso-platform-token", identity: {} };
|
|
66
|
+
const materialized = await turso.materialize(ctx, resource, auth);
|
|
67
|
+
expect(materialized.secrets.TURSO_DATABASE_URL).toBe("libsql://stack-xyz-my-org.turso.io");
|
|
68
|
+
expect(materialized.secrets.TURSO_AUTH_TOKEN).toBe(jwt);
|
|
69
|
+
expect(materialized.secrets.TURSO_PLATFORM_TOKEN).toBe("turso-platform-token");
|
|
70
|
+
});
|
|
71
|
+
});
|