@specific.dev/spectest 0.44.0 → 0.46.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 +28 -8
- package/dist/harness/http-body.d.ts +55 -0
- package/dist/harness/http-body.js +116 -0
- package/dist/recorder.d.ts +33 -2
- 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 +33 -9
- package/src/harness/http-body.test.ts +118 -0
- package/src/harness/http-body.ts +122 -0
- package/src/recorder.ts +34 -2
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Wiring test for `supabase({ dir })`: a real folder on disk in, container
|
|
2
|
+
// config out. The mapping itself is covered in supabase-config.test.ts — this
|
|
3
|
+
// is the half that decides WHICH SERVICE each setting lands on, which is where
|
|
4
|
+
// a config key quietly goes missing.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { google } from "./google.js";
|
|
12
|
+
import { supabase } from "./supabase.js";
|
|
13
|
+
import type { ServiceConfig, ServicesMap } from "../index.js";
|
|
14
|
+
|
|
15
|
+
let root: string;
|
|
16
|
+
const prevWorkspace = process.env.SPECTEST_WORKSPACE;
|
|
17
|
+
|
|
18
|
+
const CONFIG = `
|
|
19
|
+
project_id = "harmony"
|
|
20
|
+
|
|
21
|
+
[api]
|
|
22
|
+
schemas = ["public", "graphql_public", "billing"]
|
|
23
|
+
port = 54321
|
|
24
|
+
|
|
25
|
+
[auth]
|
|
26
|
+
site_url = "http://localhost:3000"
|
|
27
|
+
additional_redirect_urls = ["http://localhost:3000/**"]
|
|
28
|
+
|
|
29
|
+
[auth.email]
|
|
30
|
+
enable_confirmations = true
|
|
31
|
+
|
|
32
|
+
[auth.email.smtp]
|
|
33
|
+
admin_email = "noreply@harmony.test"
|
|
34
|
+
sender_name = "Harmony"
|
|
35
|
+
|
|
36
|
+
[auth.email.template.invite]
|
|
37
|
+
subject = "You've been invited"
|
|
38
|
+
content_path = "./supabase/templates/invite.html"
|
|
39
|
+
|
|
40
|
+
[auth.external.google]
|
|
41
|
+
enabled = true
|
|
42
|
+
client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
|
|
43
|
+
|
|
44
|
+
[auth.external.facebook]
|
|
45
|
+
enabled = true
|
|
46
|
+
|
|
47
|
+
[auth.oauth_server]
|
|
48
|
+
enabled = true
|
|
49
|
+
authorization_url_path = "/oauth/consent"
|
|
50
|
+
allow_dynamic_registration = true
|
|
51
|
+
|
|
52
|
+
[auth.hook.before_user_created]
|
|
53
|
+
enabled = true
|
|
54
|
+
uri = "pg-functions://postgres/public/hook_restrict_signup"
|
|
55
|
+
|
|
56
|
+
[edge_runtime.secrets]
|
|
57
|
+
GREETING_STYLE = "friendly"
|
|
58
|
+
STRIPE_KEY = "env(STRIPE_SECRET_KEY)"
|
|
59
|
+
SENDGRID_KEY = "encrypted:c2VjcmV0"
|
|
60
|
+
|
|
61
|
+
[functions.public-api]
|
|
62
|
+
verify_jwt = false
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
beforeAll(() => {
|
|
66
|
+
root = mkdtempSync(join(tmpdir(), "sb-dir-"));
|
|
67
|
+
mkdirSync(join(root, "supabase/templates"), { recursive: true });
|
|
68
|
+
mkdirSync(join(root, "supabase/migrations"), { recursive: true });
|
|
69
|
+
mkdirSync(join(root, "supabase/functions/public-api"), { recursive: true });
|
|
70
|
+
writeFileSync(join(root, "supabase/config.toml"), CONFIG);
|
|
71
|
+
writeFileSync(join(root, "supabase/templates/invite.html"), "<p>{{ .Token }}</p>");
|
|
72
|
+
writeFileSync(join(root, "supabase/functions/public-api/index.ts"), "Deno.serve(() => new Response());");
|
|
73
|
+
// A second project whose folder holds only SQL — the pieces are individually
|
|
74
|
+
// optional inside `dir`.
|
|
75
|
+
mkdirSync(join(root, "sql-only/migrations"), { recursive: true });
|
|
76
|
+
process.env.SPECTEST_WORKSPACE = root;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
if (prevWorkspace === undefined) delete process.env.SPECTEST_WORKSPACE;
|
|
81
|
+
else process.env.SPECTEST_WORKSPACE = prevWorkspace;
|
|
82
|
+
rmSync(root, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Expand the group the way `defineEnvironment` does, to the final service map. */
|
|
86
|
+
function expand(stack: ReturnType<typeof supabase>, name = "supabase"): ServicesMap {
|
|
87
|
+
return stack.group.services({
|
|
88
|
+
name,
|
|
89
|
+
key: (part) => (part === "gateway" ? name : `${name}-${part}`),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const svc = (parts: ServicesMap, key: string): ServiceConfig => parts[key] as ServiceConfig;
|
|
94
|
+
|
|
95
|
+
describe("supabase({ dir })", () => {
|
|
96
|
+
test("sends each config setting to the service that owns it", () => {
|
|
97
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
98
|
+
|
|
99
|
+
expect(svc(parts, "auth").env).toMatchObject({
|
|
100
|
+
GOTRUE_HOOK_BEFORE_USER_CREATED_ENABLED: "true",
|
|
101
|
+
// The sender identity comes from the file; the transport stays ours.
|
|
102
|
+
GOTRUE_SMTP_ADMIN_EMAIL: "noreply@harmony.test",
|
|
103
|
+
GOTRUE_SMTP_SENDER_NAME: "Harmony",
|
|
104
|
+
GOTRUE_SMTP_HOST: "supabase-mail",
|
|
105
|
+
});
|
|
106
|
+
expect(svc(parts, "rest").env).toMatchObject({
|
|
107
|
+
PGRST_DB_SCHEMAS: "public,graphql_public,billing",
|
|
108
|
+
});
|
|
109
|
+
expect(JSON.parse(svc(parts, "functions").env!.VERIFY_JWT_MAP!)).toEqual({
|
|
110
|
+
"public-api": false,
|
|
111
|
+
});
|
|
112
|
+
// `[edge_runtime.secrets]` — where function secrets live now.
|
|
113
|
+
expect(svc(parts, "functions").env!.GREETING_STYLE).toBe("friendly");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The one that used to be an SDK option (`mail: { autoconfirm: false }`).
|
|
117
|
+
test("takes the confirmation flow from the config file", () => {
|
|
118
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
119
|
+
expect(svc(parts, "auth").env!.GOTRUE_MAILER_AUTOCONFIRM).toBe("false");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("serves the custom invite template to GoTrue over HTTP", () => {
|
|
123
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
124
|
+
|
|
125
|
+
expect(svc(parts, "templates").files).toEqual([
|
|
126
|
+
{ path: "/srv/email/invite.html", content: "<p>{{ .Token }}</p>" },
|
|
127
|
+
]);
|
|
128
|
+
// A path is not enough — GoTrue fetches the template over HTTP.
|
|
129
|
+
expect(svc(parts, "auth").env).toMatchObject({
|
|
130
|
+
GOTRUE_MAILER_TEMPLATES_INVITE: "http://supabase-templates:8088/email/invite.html",
|
|
131
|
+
GOTRUE_MAILER_SUBJECTS_INVITE: "You've been invited",
|
|
132
|
+
});
|
|
133
|
+
expect(svc(parts, "auth").dependsOn).toContain("templates");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// The config's URLs name the developer's machine, where nothing is listening
|
|
137
|
+
// inside a VM — so addresses come from the `auth` option, never the file.
|
|
138
|
+
test("ignores the config's addresses and defaults to an open allow list", () => {
|
|
139
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
140
|
+
expect(svc(parts, "auth").env).toMatchObject({
|
|
141
|
+
GOTRUE_SITE_URL: "http://supabase:8000",
|
|
142
|
+
GOTRUE_URI_ALLOW_LIST: "*",
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("takes this environment's addresses from the auth option", () => {
|
|
147
|
+
const parts = expand(
|
|
148
|
+
supabase({
|
|
149
|
+
dir: "supabase",
|
|
150
|
+
auth: { siteUrl: "https://app.test", additionalRedirectUrls: ["https://app.test/**"] },
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
expect(svc(parts, "auth").env).toMatchObject({
|
|
154
|
+
GOTRUE_SITE_URL: "https://app.test",
|
|
155
|
+
GOTRUE_URI_ALLOW_LIST: "https://app.test/**",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("auth: false still drops GoTrue", () => {
|
|
160
|
+
const parts = expand(supabase({ auth: false }));
|
|
161
|
+
expect(parts.auth).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Supabase Auth acting as an OAuth authorization server. Its endpoints have
|
|
165
|
+
// to bypass the gateway's key-auth, because the client holding them is a
|
|
166
|
+
// third-party connector with no Supabase key at all.
|
|
167
|
+
test("opens the OAuth-server endpoints at the gateway when the config enables it", () => {
|
|
168
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
169
|
+
|
|
170
|
+
expect(svc(parts, "auth").env).toMatchObject({
|
|
171
|
+
GOTRUE_OAUTH_SERVER_ENABLED: "true",
|
|
172
|
+
// Note the rename: `authorization_url_path` -> `..._AUTHORIZATION_PATH`.
|
|
173
|
+
GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH: "/oauth/consent",
|
|
174
|
+
GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: "true",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const kong = String(svc(parts, "gateway").files?.[0]!.content);
|
|
178
|
+
expect(kong).toContain("auth-v1-open-oauth");
|
|
179
|
+
// RFC 8414 discovery: the issuer has a path, so a compliant client asks
|
|
180
|
+
// with that path inserted.
|
|
181
|
+
expect(kong).toContain("/.well-known/oauth-authorization-server/auth/v1");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("leaves those routes out when no config asks for them", () => {
|
|
185
|
+
const kong = String(svc(expand(supabase()), "gateway").files?.[0]!.content);
|
|
186
|
+
expect(kong).not.toContain("auth-v1-open-oauth");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// `env` is the `.env` this environment does not have: keyed by the name
|
|
190
|
+
// inside `env(...)`, exactly as the real file is.
|
|
191
|
+
test("resolves what the config reads from the environment", () => {
|
|
192
|
+
const parts = expand(
|
|
193
|
+
supabase({ dir: "supabase", env: { STRIPE_SECRET_KEY: "sk_test_123" } }),
|
|
194
|
+
);
|
|
195
|
+
expect(svc(parts, "functions").env).toMatchObject({
|
|
196
|
+
GREETING_STYLE: "friendly", // a literal in the file
|
|
197
|
+
STRIPE_KEY: "sk_test_123", // STRIPE_KEY = env(STRIPE_SECRET_KEY)
|
|
198
|
+
});
|
|
199
|
+
// An inline ciphertext names nothing and is never guessed at.
|
|
200
|
+
expect(svc(parts, "functions").env!.SENDGRID_KEY).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("reads a provider's credentials from the same place", () => {
|
|
204
|
+
const parts = expand(
|
|
205
|
+
supabase({ dir: "supabase", env: { GOOGLE_OAUTH_CLIENT_ID: "1234.apps.googleusercontent.com" } }),
|
|
206
|
+
);
|
|
207
|
+
expect(svc(parts, "auth").env!.GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID).toBe(
|
|
208
|
+
"1234.apps.googleusercontent.com",
|
|
209
|
+
);
|
|
210
|
+
// Nothing supplied the secret, so both sides get the stack's own.
|
|
211
|
+
expect(svc(parts, "auth").env!.GOTRUE_EXTERNAL_GOOGLE_SECRET).toBe("spectest-google-secret");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// What reaches a function is what the config declares. A value supplied
|
|
215
|
+
// here and referenced nowhere reaches nothing — and says so in the boot log,
|
|
216
|
+
// since the alternative is a function silently going without.
|
|
217
|
+
test("passes on only what the config declares", () => {
|
|
218
|
+
const parts = expand(supabase({ dir: "supabase", env: { UNDECLARED_FLAG: "true" } }));
|
|
219
|
+
expect(svc(parts, "functions").env!.UNDECLARED_FLAG).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("never passes a deferred value through as written", () => {
|
|
223
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
224
|
+
const env = svc(parts, "functions").env!;
|
|
225
|
+
expect(env.STRIPE_KEY).toBeUndefined();
|
|
226
|
+
expect(env.SENDGRID_KEY).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Social sign-in: the provider is stood up in-environment at its own real
|
|
230
|
+
// endpoints, and GoTrue is pointed at it.
|
|
231
|
+
test("serves a provider the config enables, and tells GoTrue about it", () => {
|
|
232
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
233
|
+
|
|
234
|
+
expect(parts.google).toBeDefined();
|
|
235
|
+
expect(svc(parts, "auth").dependsOn).toContain("google");
|
|
236
|
+
expect(svc(parts, "auth").env).toMatchObject({
|
|
237
|
+
GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true",
|
|
238
|
+
// The file's value is `env(GOOGLE_OAUTH_CLIENT_ID)` — a reference to a
|
|
239
|
+
// shell this environment does not have — so the stack supplies its own.
|
|
240
|
+
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "spectest-google-client",
|
|
241
|
+
GOTRUE_EXTERNAL_GOOGLE_SECRET: "spectest-google-secret",
|
|
242
|
+
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "http://supabase:8000/auth/v1/callback",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// A provider with no emulator is reported, not served — there is nothing to
|
|
247
|
+
// point GoTrue at, and pretending otherwise would fail mid-flow.
|
|
248
|
+
test("leaves a provider it cannot emulate out of the stack", () => {
|
|
249
|
+
const parts = expand(supabase({ dir: "supabase" }));
|
|
250
|
+
expect(parts.facebook).toBeUndefined();
|
|
251
|
+
expect(svc(parts, "auth").env!.GOTRUE_EXTERNAL_FACEBOOK_ENABLED).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("takes a provider component the caller configured instead", () => {
|
|
255
|
+
const configured = google({ users: [{ email: "carol@corp.test", name: "Carol Corp" }] });
|
|
256
|
+
const parts = expand(supabase({ dir: "supabase", auth: { providers: { google: configured } } }));
|
|
257
|
+
expect(parts.google).toBe(configured);
|
|
258
|
+
// Still wired: the override changes the accounts, not the plumbing.
|
|
259
|
+
expect(svc(parts, "auth").env!.GOTRUE_EXTERNAL_GOOGLE_ENABLED).toBe("true");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("each piece inside the folder is optional", () => {
|
|
263
|
+
const parts = expand(supabase({ dir: "sql-only" }));
|
|
264
|
+
expect(parts.templates).toBeUndefined();
|
|
265
|
+
expect(parts["supabase-functions"]).toBeUndefined();
|
|
266
|
+
// Defaults intact where the folder says nothing.
|
|
267
|
+
expect(svc(parts, "auth").env!.GOTRUE_MAILER_AUTOCONFIRM).toBe("true");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("stands up a bare stack with no folder at all", () => {
|
|
271
|
+
const parts = expand(supabase());
|
|
272
|
+
expect(Object.keys(parts).sort()).toEqual([
|
|
273
|
+
"auth",
|
|
274
|
+
"db",
|
|
275
|
+
"gateway",
|
|
276
|
+
"imgproxy",
|
|
277
|
+
"mail",
|
|
278
|
+
"realtime",
|
|
279
|
+
"rest",
|
|
280
|
+
"storage",
|
|
281
|
+
]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("names a folder that isn't there instead of failing later", () => {
|
|
285
|
+
expect(() => supabase({ dir: "nope" })).toThrow(/does not resolve/);
|
|
286
|
+
});
|
|
287
|
+
});
|