@specific.dev/spectest 0.45.0 → 0.47.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/components/supabase-config.d.ts +89 -0
- package/dist/components/supabase-config.js +447 -0
- package/dist/components/supabase.d.ts +124 -84
- package/dist/components/supabase.js +395 -136
- package/dist/daemon.js +9 -2
- package/dist/harness/container-run.d.ts +6 -0
- package/dist/harness/container-run.js +13 -0
- package/package.json +1 -1
- package/src/components/supabase-config.test.ts +261 -0
- package/src/components/supabase-config.ts +543 -0
- package/src/components/supabase-dir.test.ts +287 -0
- package/src/components/supabase.ts +581 -230
- package/src/daemon.ts +11 -2
- package/src/harness/container-run.test.ts +8 -0
- package/src/harness/container-run.ts +13 -0
package/dist/daemon.js
CHANGED
|
@@ -933,8 +933,15 @@ async function ensureCaTrustedImage(serviceName, tag) {
|
|
|
933
933
|
const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
|
|
934
934
|
await fs.mkdir(ctxDir, { recursive: true });
|
|
935
935
|
await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
|
|
936
|
+
// An image that declares a non-root `USER` runs this step as that user,
|
|
937
|
+
// and writing the trust store then fails ("cannot create
|
|
938
|
+
// /etc/ssl/certs/ca-certificates.crt.new: Permission denied") — the CA
|
|
939
|
+
// silently never lands. Take root for the one command, then hand the
|
|
940
|
+
// image back its own user so containers still run as it.
|
|
941
|
+
const declaredUser = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
|
|
942
|
+
const needsRoot = declaredUser !== "" && declaredUser !== "root" && declaredUser !== "0";
|
|
936
943
|
const dockerfile = `FROM ${tag}
|
|
937
|
-
COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
944
|
+
${needsRoot ? "USER root\n" : ""}COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
938
945
|
RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
939
946
|
update-ca-certificates; \\
|
|
940
947
|
elif command -v update-ca-trust >/dev/null 2>&1; then \\
|
|
@@ -942,7 +949,7 @@ RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
|
942
949
|
else \\
|
|
943
950
|
echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
|
|
944
951
|
fi
|
|
945
|
-
`;
|
|
952
|
+
${needsRoot ? `USER ${declaredUser}\n` : ""}`;
|
|
946
953
|
await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
|
|
947
954
|
const build = await docker(["build", "-t", tag, ctxDir], 300_000);
|
|
948
955
|
if (build.code !== 0) {
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* showed the problem, which is why it went unnoticed: only the appending
|
|
30
30
|
* variable was in play.
|
|
31
31
|
*
|
|
32
|
+
* **Deno is a third mechanism.** It reads none of those variables and
|
|
33
|
+
* trusts only its bundled Mozilla roots, so an edge function calling a
|
|
34
|
+
* fake fails with `UnknownIssuer` however the image is set up.
|
|
35
|
+
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
|
+
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
|
+
*
|
|
32
38
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
33
39
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
34
40
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* showed the problem, which is why it went unnoticed: only the appending
|
|
30
30
|
* variable was in play.
|
|
31
31
|
*
|
|
32
|
+
* **Deno is a third mechanism.** It reads none of those variables and
|
|
33
|
+
* trusts only its bundled Mozilla roots, so an edge function calling a
|
|
34
|
+
* fake fails with `UnknownIssuer` however the image is set up.
|
|
35
|
+
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
|
+
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
|
+
*
|
|
32
38
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
33
39
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
34
40
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -87,6 +93,13 @@ export function runContainerArgs(input) {
|
|
|
87
93
|
// replacing ones take the combined bundle.
|
|
88
94
|
args.push(`--volume=${input.caPath}:${input.caPath}:ro`);
|
|
89
95
|
args.push("-e", `NODE_EXTRA_CA_CERTS=${input.caPath}`);
|
|
96
|
+
// Deno reads none of the above, and its default store is the bundled
|
|
97
|
+
// Mozilla roots alone — so a Deno runtime (a Supabase edge function is
|
|
98
|
+
// the usual one) rejects every certificate this environment mints, with
|
|
99
|
+
// or without the CA layered into the image. `system` adds the platform
|
|
100
|
+
// store, which resolves through `SSL_CERT_FILE`: the bundle below.
|
|
101
|
+
// `mozilla` stays first so the public roots survive.
|
|
102
|
+
args.push("-e", "DENO_TLS_CA_STORE=mozilla,system");
|
|
90
103
|
const bundle = input.caBundle;
|
|
91
104
|
if (bundle) {
|
|
92
105
|
if (bundle !== input.caPath)
|
package/package.json
CHANGED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { mapSupabaseConfig } from "./supabase-config.js";
|
|
4
|
+
|
|
5
|
+
const toml = (s: string): unknown =>
|
|
6
|
+
(globalThis as unknown as { Bun: { TOML: { parse(s: string): unknown } } }).Bun.TOML.parse(s);
|
|
7
|
+
|
|
8
|
+
describe("mapSupabaseConfig", () => {
|
|
9
|
+
test("maps the auth settings a test can observe", () => {
|
|
10
|
+
const out = mapSupabaseConfig(
|
|
11
|
+
toml(`
|
|
12
|
+
[auth]
|
|
13
|
+
jwt_expiry = 7200
|
|
14
|
+
minimum_password_length = 10
|
|
15
|
+
|
|
16
|
+
[auth.email]
|
|
17
|
+
otp_length = 8
|
|
18
|
+
|
|
19
|
+
[auth.hook.before_user_created]
|
|
20
|
+
enabled = true
|
|
21
|
+
uri = "pg-functions://postgres/public/hook_restrict_signup"
|
|
22
|
+
`),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(out.auth).toMatchObject({
|
|
26
|
+
GOTRUE_JWT_EXP: "7200",
|
|
27
|
+
GOTRUE_PASSWORD_MIN_LENGTH: "10",
|
|
28
|
+
GOTRUE_MAILER_OTP_LENGTH: "8",
|
|
29
|
+
GOTRUE_HOOK_BEFORE_USER_CREATED_ENABLED: "true",
|
|
30
|
+
GOTRUE_HOOK_BEFORE_USER_CREATED_URI: "pg-functions://postgres/public/hook_restrict_signup",
|
|
31
|
+
});
|
|
32
|
+
expect(out.warnings).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The two mappings a copy-paste gets backwards without anyone noticing.
|
|
36
|
+
test("negates enable_signup and enable_confirmations", () => {
|
|
37
|
+
const on = mapSupabaseConfig(
|
|
38
|
+
toml(`
|
|
39
|
+
[auth]
|
|
40
|
+
enable_signup = true
|
|
41
|
+
[auth.email]
|
|
42
|
+
enable_confirmations = true
|
|
43
|
+
`),
|
|
44
|
+
);
|
|
45
|
+
expect(on.auth.GOTRUE_DISABLE_SIGNUP).toBe("false");
|
|
46
|
+
expect(on.auth.GOTRUE_MAILER_AUTOCONFIRM).toBe("false");
|
|
47
|
+
|
|
48
|
+
const off = mapSupabaseConfig(
|
|
49
|
+
toml(`
|
|
50
|
+
[auth]
|
|
51
|
+
enable_signup = false
|
|
52
|
+
[auth.email]
|
|
53
|
+
enable_confirmations = false
|
|
54
|
+
`),
|
|
55
|
+
);
|
|
56
|
+
expect(off.auth.GOTRUE_DISABLE_SIGNUP).toBe("true");
|
|
57
|
+
expect(off.auth.GOTRUE_MAILER_AUTOCONFIRM).toBe("true");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("collects mail templates by id", () => {
|
|
61
|
+
const out = mapSupabaseConfig(
|
|
62
|
+
toml(`
|
|
63
|
+
[auth.email.template.invite]
|
|
64
|
+
subject = "You've been invited"
|
|
65
|
+
content_path = "./supabase/templates/invite.html"
|
|
66
|
+
`),
|
|
67
|
+
);
|
|
68
|
+
expect(out.templates.invite).toEqual({
|
|
69
|
+
path: "./supabase/templates/invite.html",
|
|
70
|
+
subject: "You've been invited",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// A subject with no content_path is how the CLI spells "default body, custom
|
|
75
|
+
// subject" — it must not invent a template path.
|
|
76
|
+
test("keeps a subject-only template out of the served set", () => {
|
|
77
|
+
const out = mapSupabaseConfig(
|
|
78
|
+
toml(`
|
|
79
|
+
[auth.email.template.recovery]
|
|
80
|
+
subject = "Reset it"
|
|
81
|
+
content_path = ""
|
|
82
|
+
`),
|
|
83
|
+
);
|
|
84
|
+
expect(out.templates.recovery).toEqual({ path: "", subject: "Reset it" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("reads per-function verify_jwt and the api schemas", () => {
|
|
88
|
+
const out = mapSupabaseConfig(
|
|
89
|
+
toml(`
|
|
90
|
+
[api]
|
|
91
|
+
schemas = ["public", "graphql_public"]
|
|
92
|
+
max_rows = 500
|
|
93
|
+
port = 54321
|
|
94
|
+
|
|
95
|
+
[functions.invoice-api]
|
|
96
|
+
verify_jwt = false
|
|
97
|
+
[functions.account-api]
|
|
98
|
+
verify_jwt = true
|
|
99
|
+
`),
|
|
100
|
+
);
|
|
101
|
+
expect(out.functionVerifyJwt).toEqual({ "invoice-api": false, "account-api": true });
|
|
102
|
+
expect(out.rest).toEqual({ PGRST_DB_SCHEMAS: "public,graphql_public", PGRST_DB_MAX_ROWS: "500" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Addresses are this environment's, not the file's: its URLs name the
|
|
106
|
+
// developer's machine, where nothing is listening inside a VM.
|
|
107
|
+
test("never takes site_url or the redirect allow list from the file", () => {
|
|
108
|
+
const out = mapSupabaseConfig(
|
|
109
|
+
toml(`
|
|
110
|
+
[auth]
|
|
111
|
+
site_url = "http://localhost:3000"
|
|
112
|
+
additional_redirect_urls = ["http://localhost:3000/**"]
|
|
113
|
+
`),
|
|
114
|
+
);
|
|
115
|
+
expect(out.auth).toEqual({});
|
|
116
|
+
expect(out.unsupported).toEqual([]);
|
|
117
|
+
expect(out.warnings).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Reported, never fatal — the file belongs to the project and describes more
|
|
121
|
+
// environments than this one.
|
|
122
|
+
test("reports a setting it cannot apply, with the reason", () => {
|
|
123
|
+
const out = mapSupabaseConfig(
|
|
124
|
+
toml(`
|
|
125
|
+
[auth.captcha]
|
|
126
|
+
enabled = true
|
|
127
|
+
provider = "hcaptcha"
|
|
128
|
+
secret = "env(HCAPTCHA_SECRET)"
|
|
129
|
+
`),
|
|
130
|
+
);
|
|
131
|
+
expect(out.unsupported).toHaveLength(1);
|
|
132
|
+
expect(out.unsupported[0]!.key).toBe("auth.captcha");
|
|
133
|
+
expect(out.unsupported[0]!.reason).toMatch(/captcha/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Social sign-in is collected, not refused: the component serves each
|
|
137
|
+
// provider it has an emulator for at that provider's real endpoints.
|
|
138
|
+
test("collects the social providers and where their credentials come from", () => {
|
|
139
|
+
const out = mapSupabaseConfig(
|
|
140
|
+
toml(`
|
|
141
|
+
[auth.external.google]
|
|
142
|
+
enabled = true
|
|
143
|
+
client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
|
|
144
|
+
secret = "env(GOOGLE_OAUTH_SECRET)"
|
|
145
|
+
|
|
146
|
+
[auth.external.azure]
|
|
147
|
+
enabled = true
|
|
148
|
+
client_id = "literal-azure-id"
|
|
149
|
+
url = "https://login.microsoftonline.com/tenant-1/v2.0"
|
|
150
|
+
|
|
151
|
+
[auth.external.facebook]
|
|
152
|
+
enabled = false
|
|
153
|
+
`),
|
|
154
|
+
);
|
|
155
|
+
expect(out.externalProviders.google).toEqual({
|
|
156
|
+
enabled: true,
|
|
157
|
+
clientIdFrom: "GOOGLE_OAUTH_CLIENT_ID",
|
|
158
|
+
secretFrom: "GOOGLE_OAUTH_SECRET",
|
|
159
|
+
});
|
|
160
|
+
expect(out.externalProviders.azure).toEqual({
|
|
161
|
+
enabled: true,
|
|
162
|
+
clientId: "literal-azure-id",
|
|
163
|
+
url: "https://login.microsoftonline.com/tenant-1/v2.0",
|
|
164
|
+
});
|
|
165
|
+
expect(out.externalProviders.facebook!.enabled).toBe(false);
|
|
166
|
+
expect(out.unsupported).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// The common shape: a config lists a dozen providers with every one off.
|
|
170
|
+
// Nothing is lost by not applying them, so nothing is said.
|
|
171
|
+
test("stays quiet about an unsupported feature that is switched off", () => {
|
|
172
|
+
const out = mapSupabaseConfig(
|
|
173
|
+
toml(`
|
|
174
|
+
[auth.captcha]
|
|
175
|
+
enabled = false
|
|
176
|
+
provider = "hcaptcha"
|
|
177
|
+
|
|
178
|
+
[auth.sms]
|
|
179
|
+
enable_signup = false
|
|
180
|
+
template = "code {{ .Code }}"
|
|
181
|
+
`),
|
|
182
|
+
);
|
|
183
|
+
expect(out.unsupported).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("reports a section where only one feature is enabled", () => {
|
|
187
|
+
const out = mapSupabaseConfig(
|
|
188
|
+
toml(`
|
|
189
|
+
[auth.mfa.phone]
|
|
190
|
+
enroll_enabled = true
|
|
191
|
+
verify_enabled = false
|
|
192
|
+
`),
|
|
193
|
+
);
|
|
194
|
+
expect(out.unsupported.map((u) => u.key)).toEqual(["auth.mfa.phone"]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Both ways the file defers a value to the surrounding environment. Passing
|
|
198
|
+
// either through would hand a function the string `env(NAME)` or a base64
|
|
199
|
+
// ciphertext — the latter looking enough like a value to go unnoticed.
|
|
200
|
+
test("separates literal secrets from the two deferred forms", () => {
|
|
201
|
+
const out = mapSupabaseConfig(
|
|
202
|
+
toml(`
|
|
203
|
+
[edge_runtime.secrets]
|
|
204
|
+
FEATURE_FLAG = "true"
|
|
205
|
+
STRIPE_KEY = "env(STRIPE_SECRET_KEY)"
|
|
206
|
+
SENDGRID_KEY = "encrypted:c2VjcmV0LXZhbHVl"
|
|
207
|
+
`),
|
|
208
|
+
);
|
|
209
|
+
expect(out.functionSecrets).toEqual({ FEATURE_FLAG: "true" });
|
|
210
|
+
// `env(NAME)` records the name to read it from…
|
|
211
|
+
expect(out.deferredSecrets).toEqual({ STRIPE_KEY: "STRIPE_SECRET_KEY" });
|
|
212
|
+
// …while an inline ciphertext names nothing and cannot be supplied here.
|
|
213
|
+
expect(out.encryptedValues).toEqual(["edge_runtime.secrets.SENDGRID_KEY"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// A provider credential reads from the same `.env` as everything else — it
|
|
217
|
+
// is a field on the project's auth settings rather than a named secret, but
|
|
218
|
+
// the value gets there the same way.
|
|
219
|
+
test("records where a provider credential is read from", () => {
|
|
220
|
+
const out = mapSupabaseConfig(
|
|
221
|
+
toml(`
|
|
222
|
+
[auth.external.google]
|
|
223
|
+
enabled = true
|
|
224
|
+
client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
|
|
225
|
+
secret = "encrypted:c2hoaA=="
|
|
226
|
+
`),
|
|
227
|
+
);
|
|
228
|
+
expect(out.externalProviders.google).toEqual({
|
|
229
|
+
enabled: true,
|
|
230
|
+
clientIdFrom: "GOOGLE_OAUTH_CLIENT_ID",
|
|
231
|
+
});
|
|
232
|
+
// The ciphertext names nothing, so it is reported instead of guessed at.
|
|
233
|
+
expect(out.encryptedValues).toEqual(["auth.external.google.secret"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("warns rather than fails on a key it has never seen", () => {
|
|
237
|
+
const out = mapSupabaseConfig(toml(`\n[auth]\nfuture_option = true\n`));
|
|
238
|
+
expect(out.warnings).toEqual(["auth.future_option"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Sections spectest owns outright never reach the user as noise.
|
|
242
|
+
test("drops the topology sections in silence", () => {
|
|
243
|
+
const out = mapSupabaseConfig(
|
|
244
|
+
toml(`
|
|
245
|
+
project_id = "test"
|
|
246
|
+
[db]
|
|
247
|
+
port = 54322
|
|
248
|
+
[db.pooler]
|
|
249
|
+
enabled = true
|
|
250
|
+
[studio]
|
|
251
|
+
port = 54323
|
|
252
|
+
[inbucket]
|
|
253
|
+
port = 54324
|
|
254
|
+
[remotes.production.auth]
|
|
255
|
+
site_url = "https://example.com"
|
|
256
|
+
`),
|
|
257
|
+
);
|
|
258
|
+
expect(out.warnings).toEqual([]);
|
|
259
|
+
expect(out.auth).toEqual({});
|
|
260
|
+
});
|
|
261
|
+
});
|