@specific.dev/spectest 0.14.0 → 0.16.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.
@@ -63,6 +63,7 @@ import {
63
63
  type ServicesMap,
64
64
  } from "../index.js";
65
65
  import { SQL, type SqlClient } from "../sql.js";
66
+ import { email, emailHelpers, type EmailHelpers } from "./email.js";
66
67
 
67
68
  // ──────────────────────────────────────────────────────────────────────────
68
69
  // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
@@ -132,12 +133,45 @@ export interface SupabaseOptions {
132
133
  */
133
134
  hostname?: string;
134
135
 
136
+ /**
137
+ * Capture GoTrue's outgoing mail (magic links, OTPs, password recovery,
138
+ * invites — and signup confirmations with `autoconfirm: false`) with the
139
+ * standard `email()` component. **On by default**: the stack includes the
140
+ * capture server (`<name>-mail`) with GoTrue's SMTP wired at it, and tests
141
+ * read the mailbox through the typed helpers at `ctx.svc.<name>.mail`
142
+ * (`lastEmail` / `emails` / `clear`), each captured message rendering
143
+ * mail-client-style on the dashboard timeline.
144
+ *
145
+ * Signups stay one-step by default (`autoconfirm: true`) so the mailbox is
146
+ * purely additive; pass `{ autoconfirm: false }` to test real
147
+ * email-confirmation flows. Pass `{ service: "<key>" }` to point GoTrue at
148
+ * an `email()` service the environment already declares — keep a single
149
+ * mailbox per environment rather than one per component: if the app under
150
+ * test also sends mail, share one `email()` between it and Supabase
151
+ * instead of standing up a second capture server. `false` disables mail
152
+ * capture entirely.
153
+ */
154
+ mail?: boolean | SupabaseMailOptions;
155
+
156
+ /**
157
+ * Extra API keys the gateway accepts alongside the derived JWT keys — for
158
+ * clients that ship a hardcoded key (e.g. an `sb_publishable_…` /
159
+ * `sb_secret_…` pair baked into an app build). Each `anon` key is admitted
160
+ * with anon privileges and each `serviceRole` key with service-role
161
+ * privileges; Kong swaps the matching *derived JWT* in before proxying, so
162
+ * GoTrue/PostgREST/Realtime still receive a validly-signed token.
163
+ */
164
+ extraApiKeys?: { anon?: string[]; serviceRole?: string[] };
165
+
135
166
  /**
136
167
  * Auto-apply SQL migrations found in the project before any dependent service
137
168
  * starts. `true` (default) applies every `*.sql` under `supabase/migrations/`
138
169
  * (sorted, the Supabase convention), then `supabase/seed.sql` if present — a
139
170
  * no-op when the directory is absent. Pass a string to point at a different
140
171
  * directory (relative to the project root or absolute), or `false` to skip.
172
+ * An *explicitly configured* path that doesn't resolve is an error at env
173
+ * start (a silent no-op there surfaces much later as a confusing
174
+ * `relation … does not exist`).
141
175
  * Because `supabase/**` is project content, editing a migration correctly
142
176
  * forces a cold rebuild and re-apply (unlike `spectest/tests/**`).
143
177
  */
@@ -150,6 +184,28 @@ export interface SupabaseOptions {
150
184
  seed?: boolean | string;
151
185
  }
152
186
 
187
+ /** Tuning for `SupabaseOptions.mail`. */
188
+ export interface SupabaseMailOptions {
189
+ /**
190
+ * Reuse an `email()` service the environment already declares (its
191
+ * services-map key) instead of adding one to the group — the way to keep a
192
+ * single global mailbox when the app under test sends mail too. Assumes
193
+ * the service's default ports (SMTP 1025, API 8025).
194
+ */
195
+ service?: string;
196
+ /**
197
+ * Whether GoTrue autoconfirms signups. `true` (default): signups complete
198
+ * in one step and send no confirmation mail — recovery, magic-link and OTP
199
+ * mail is still sent and captured. Set `false` to exercise real
200
+ * email-confirmation flows (signups require the emailed verification).
201
+ */
202
+ autoconfirm?: boolean;
203
+ /** Sender address GoTrue mails from. Default `admin@example.com`. */
204
+ adminEmail?: string;
205
+ /** Sender display name. Default `Supabase`. */
206
+ senderName?: string;
207
+ }
208
+
153
209
  /** Helpers the gateway service exposes on `ctx.svc.<name>`. */
154
210
  export interface SupabaseHelpers {
155
211
  /**
@@ -165,6 +221,18 @@ export interface SupabaseHelpers {
165
221
  anonKey: string;
166
222
  /** Long-lived `service_role` API key (HS256 JWT). Bypasses RLS — server-side only. */
167
223
  serviceRoleKey: string;
224
+ /**
225
+ * The captured-mail mailbox (`lastEmail` / `emails` / `clear`) — present
226
+ * unless the stack was built with `mail: false`. Wait for delivery with
227
+ * the standard `ctx.poll`:
228
+ *
229
+ * ```ts
230
+ * const mail = await ctx.poll("confirmation email", () =>
231
+ * ctx.svc.supabase.mail!.lastEmail());
232
+ * expect(mail.to).toContain("alice@example.com");
233
+ * ```
234
+ */
235
+ mail?: EmailHelpers;
168
236
  }
169
237
 
170
238
  /** What `supabase()` returns: the service group plus the derived
@@ -185,6 +253,12 @@ export interface SupabaseStack {
185
253
  /** The gateway service key — use in a dependent service's `dependsOn` to wait
186
254
  * for the whole stack (the gateway waits on the services behind it). */
187
255
  ready: string;
256
+ /**
257
+ * Where the stack's SMTP capture server listens (absent with
258
+ * `mail: false`) — point the app under test's own mail transport here to
259
+ * share the one mailbox: `env: { SMTP_HOST: sb.smtp.host, SMTP_PORT: String(sb.smtp.port) }`.
260
+ */
261
+ smtp?: { host: string; port: number };
188
262
  /** Ready-to-spread env for the app under test: `SUPABASE_URL`,
189
263
  * `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
190
264
  appEnv: Record<string, string>;
@@ -277,12 +351,32 @@ function buildBootstrapSql(
277
351
  // ──────────────────────────────────────────────────────────────────────────
278
352
 
279
353
  // The two Lua router expressions kong-entrypoint.sh emits when the opaque
280
- // `sb_`-style keys are NOT configured (our case): pass the incoming apikey (a
281
- // legacy HS256 JWT) through unchanged, preferring an existing non-`sb_`
282
- // Authorization header.
283
- const LUA_AUTH_EXPR =
284
- "$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)";
285
- const LUA_RT_WS_EXPR = "$(query_params.apikey)";
354
+ // `sb_`-style keys are NOT configured: pass the incoming apikey (a legacy
355
+ // HS256 JWT) through unchanged, preferring an existing non-`sb_`
356
+ // Authorization header. `extraApiKeys` extends both: an extra key (arriving
357
+ // as `apikey` header, `Authorization: Bearer <key>`, or the websocket
358
+ // `?apikey=` query param) is swapped for the corresponding *derived JWT*
359
+ // before proxying, so the upstream services still see a validly-signed token.
360
+ function buildLuaExprs(
361
+ extra: Array<{ key: string; jwt: string }>,
362
+ ): { auth: string; realtimeWs: string } {
363
+ // Keep a client's `Authorization: Bearer <extraKey>` from winning the
364
+ // header-passthrough clause with the raw (non-JWT) key.
365
+ const guards = extra
366
+ .map((m) => ` and headers.authorization ~= 'Bearer ${m.key}'`)
367
+ .join("");
368
+ const mappings = extra
369
+ .map(
370
+ (m) =>
371
+ ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}') and 'Bearer ${m.jwt}')`,
372
+ )
373
+ .join("");
374
+ const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey)`;
375
+ const wsMappings = extra
376
+ .map((m) => `(query_params.apikey == '${m.key}' and '${m.jwt}') or `)
377
+ .join("");
378
+ return { auth, realtimeWs: `$(${wsMappings}query_params.apikey)` };
379
+ }
286
380
 
287
381
  const KONG_YML_RAW = String.raw`_format_version: '2.1'
288
382
  _transform: true
@@ -631,9 +725,26 @@ function buildKongYaml(
631
725
  serviceRoleKey: string;
632
726
  dashboardUsername: string;
633
727
  dashboardPassword: string;
728
+ extraAnonKeys: string[];
729
+ extraServiceRoleKeys: string[];
634
730
  },
635
731
  ): string {
636
732
  let y = KONG_YML_RAW;
733
+ // Extra API keys land where kong-entrypoint.sh's opaque keys would: as
734
+ // additional keyauth credentials on the matching consumer. The template's
735
+ // placeholder lines are either expanded here or dropped by the filter below.
736
+ if (vals.extraAnonKeys.length > 0) {
737
+ y = y.replace(
738
+ " - key: $SUPABASE_PUBLISHABLE_KEY",
739
+ vals.extraAnonKeys.map((k) => ` - key: ${k}`).join("\n"),
740
+ );
741
+ }
742
+ if (vals.extraServiceRoleKeys.length > 0) {
743
+ y = y.replace(
744
+ " - key: $SUPABASE_SECRET_KEY",
745
+ vals.extraServiceRoleKeys.map((k) => ` - key: ${k}`).join("\n"),
746
+ );
747
+ }
637
748
  // Rewrite upstream bare service hosts → the group's final DNS names.
638
749
  y = y
639
750
  .replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
@@ -644,9 +755,13 @@ function buildKongYaml(
644
755
  .replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
645
756
  .replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
646
757
  // Lua expressions first (they contain no `$SUPABASE_*` tokens).
647
- y = y.replaceAll("$LUA_AUTH_EXPR", LUA_AUTH_EXPR).replaceAll(
758
+ const lua = buildLuaExprs([
759
+ ...vals.extraAnonKeys.map((k) => ({ key: k, jwt: vals.anonKey })),
760
+ ...vals.extraServiceRoleKeys.map((k) => ({ key: k, jwt: vals.serviceRoleKey })),
761
+ ]);
762
+ y = y.replaceAll("$LUA_AUTH_EXPR", lua.auth).replaceAll(
648
763
  "$LUA_RT_WS_EXPR",
649
- LUA_RT_WS_EXPR,
764
+ lua.realtimeWs,
650
765
  );
651
766
  // API keys + dashboard credentials.
652
767
  y = y
@@ -707,14 +822,17 @@ async function applyMigrations(
707
822
  ctx: ServiceSetupContext,
708
823
  cfg: { migrations: boolean | string; seed: boolean | string },
709
824
  ): Promise<void> {
710
- // Migrations directory.
825
+ // Migrations directory. An *explicitly configured* path that doesn't
826
+ // resolve is a hard error — a silent no-op here surfaces much later as a
827
+ // baffling `relation "public.…" does not exist`. Only the `true` default
828
+ // (conventional `supabase/migrations/`) is allowed to be absent.
711
829
  if (cfg.migrations !== false) {
712
- const dir =
713
- typeof cfg.migrations === "string"
714
- ? cfg.migrations.startsWith("/")
715
- ? cfg.migrations
716
- : join(ctx.projectRoot, cfg.migrations)
717
- : join(ctx.projectRoot, "supabase", "migrations");
830
+ const explicit = typeof cfg.migrations === "string";
831
+ const dir = explicit
832
+ ? (cfg.migrations as string).startsWith("/")
833
+ ? (cfg.migrations as string)
834
+ : join(ctx.projectRoot, cfg.migrations as string)
835
+ : join(ctx.projectRoot, "supabase", "migrations");
718
836
  if (existsSync(dir) && (await stat(dir)).isDirectory()) {
719
837
  const files = (await readdir(dir))
720
838
  .filter((f) => f.endsWith(".sql"))
@@ -723,19 +841,34 @@ async function applyMigrations(
723
841
  const sql = await ctx.readProjectFile(join(dir, f));
724
842
  await applySqlFile(ctx, sql, `migration ${f}`, "postgres");
725
843
  }
844
+ } else if (explicit) {
845
+ throw new Error(
846
+ `supabase: configured migrations path ${JSON.stringify(
847
+ cfg.migrations,
848
+ )} does not resolve to a directory (looked at ${dir}). ` +
849
+ `Fix the \`migrations\` option, or remove it to use the default ` +
850
+ `supabase/migrations/. Note the path is resolved inside the uploaded ` +
851
+ `project — check it isn't ignored (e.g. via .spectestignore).`,
852
+ );
726
853
  }
727
854
  }
728
- // Seed file.
855
+ // Seed file — same contract: only the conventional default may be absent.
729
856
  if (cfg.seed !== false) {
730
- const seedPath =
731
- typeof cfg.seed === "string"
732
- ? cfg.seed.startsWith("/")
733
- ? cfg.seed
734
- : join(ctx.projectRoot, cfg.seed)
735
- : join(ctx.projectRoot, "supabase", "seed.sql");
857
+ const explicit = typeof cfg.seed === "string";
858
+ const seedPath = explicit
859
+ ? (cfg.seed as string).startsWith("/")
860
+ ? (cfg.seed as string)
861
+ : join(ctx.projectRoot, cfg.seed as string)
862
+ : join(ctx.projectRoot, "supabase", "seed.sql");
736
863
  if (existsSync(seedPath)) {
737
864
  const sql = await ctx.readProjectFile(seedPath);
738
865
  await applySqlFile(ctx, sql, "seed.sql", "postgres");
866
+ } else if (explicit) {
867
+ throw new Error(
868
+ `supabase: configured seed path ${JSON.stringify(cfg.seed)} not found ` +
869
+ `(looked at ${seedPath}). Fix the \`seed\` option or remove it to ` +
870
+ `use the default supabase/seed.sql.`,
871
+ );
739
872
  }
740
873
  }
741
874
  }
@@ -773,6 +906,29 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
773
906
  const withStudio = opts.studio ?? false;
774
907
  const withMeta = opts.meta ?? withStudio;
775
908
 
909
+ // Mail capture (see SupabaseOptions.mail) — on unless explicitly disabled.
910
+ // `mailKey` is the SMTP host GoTrue mails through — the in-group part
911
+ // (`<name>-mail`) or a user-declared `email()` service shared with the app
912
+ // under test.
913
+ const withMail = opts.mail !== false;
914
+ const mailOpts: SupabaseMailOptions =
915
+ typeof opts.mail === "object" ? opts.mail : {};
916
+ const mailExternal = mailOpts.service !== undefined;
917
+ const mailKey = mailOpts.service ?? `${name}-mail`;
918
+ const mailAutoconfirm = mailOpts.autoconfirm ?? true;
919
+
920
+ const extraAnonKeys = opts.extraApiKeys?.anon ?? [];
921
+ const extraServiceRoleKeys = opts.extraApiKeys?.serviceRole ?? [];
922
+ for (const k of [...extraAnonKeys, ...extraServiceRoleKeys]) {
923
+ // The keys are spliced into Kong's YAML and its quoted Lua expressions —
924
+ // restrict to the token charset real API keys use so neither can break.
925
+ if (!/^[A-Za-z0-9._-]+$/.test(k)) {
926
+ throw new Error(
927
+ `supabase(): extraApiKeys entries must match [A-Za-z0-9._-]+ (got ${JSON.stringify(k)})`,
928
+ );
929
+ }
930
+ }
931
+
776
932
  const anonKey = apiKey("anon", jwtSecret);
777
933
  const serviceRoleKey = apiKey("service_role", jwtSecret);
778
934
 
@@ -872,6 +1028,11 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
872
1028
  readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
873
1029
  };
874
1030
 
1031
+ // ── mail (SMTP capture, the standard email() component) ─────────
1032
+ if (withMail && !mailExternal) {
1033
+ parts.mail = email();
1034
+ }
1035
+
875
1036
  // ── auth (GoTrue) ───────────────────────────────────────────────
876
1037
  if (withAuth) {
877
1038
  const apiExternalUrl = `${url}/auth/v1`;
@@ -894,14 +1055,39 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
894
1055
  GOTRUE_JWT_ISSUER: apiExternalUrl,
895
1056
  GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
896
1057
  GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
897
- // Autoconfirm so tests can sign up without a mail server
898
- // round-trip.
899
- GOTRUE_MAILER_AUTOCONFIRM: "true",
1058
+ // Autoconfirm defaults ON (one-step signups, no confirmation
1059
+ // mail) so the always-on mailbox is purely additive; the mail
1060
+ // option's `autoconfirm: false` opts into real confirmation
1061
+ // flows.
1062
+ GOTRUE_MAILER_AUTOCONFIRM:
1063
+ withMail && !mailAutoconfirm ? "false" : "true",
900
1064
  GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
901
1065
  GOTRUE_SMS_AUTOCONFIRM: "true",
1066
+ ...(withMail
1067
+ ? {
1068
+ GOTRUE_SMTP_HOST: mailKey,
1069
+ GOTRUE_SMTP_PORT: "1025",
1070
+ // Deliberately NO GOTRUE_SMTP_USER/PASS: the capture
1071
+ // server advertises AUTH, and with credentials set
1072
+ // GoTrue's Go smtp.PlainAuth refuses to send them over a
1073
+ // plaintext connection ("unencrypted connection" → every
1074
+ // mail 500s). Unauthenticated submission is accepted.
1075
+ GOTRUE_SMTP_ADMIN_EMAIL:
1076
+ mailOpts.adminEmail ?? "admin@example.com",
1077
+ GOTRUE_SMTP_SENDER_NAME: mailOpts.senderName ?? "Supabase",
1078
+ // GoTrue rate-limits repeat mail to the same address
1079
+ // (default 1/min) — far too slow for tests that
1080
+ // request an OTP, assert, and request again.
1081
+ GOTRUE_SMTP_MAX_FREQUENCY: "1s",
1082
+ GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify",
1083
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify",
1084
+ GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify",
1085
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify",
1086
+ }
1087
+ : {}),
902
1088
  },
903
1089
  ports: [9999],
904
- dependsOn: ["db"],
1090
+ dependsOn: ["db", ...(withMail && !mailExternal ? ["mail"] : [])],
905
1091
  readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
906
1092
  };
907
1093
  }
@@ -1071,6 +1257,8 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1071
1257
  serviceRoleKey,
1072
1258
  dashboardUsername,
1073
1259
  dashboardPassword,
1260
+ extraAnonKeys,
1261
+ extraServiceRoleKeys,
1074
1262
  }),
1075
1263
  },
1076
1264
  ],
@@ -1087,6 +1275,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1087
1275
  url,
1088
1276
  anonKey,
1089
1277
  serviceRoleKey,
1278
+ ...(withMail ? { mail: emailHelpers(mailKey) } : {}),
1090
1279
  }),
1091
1280
  });
1092
1281
 
@@ -1105,5 +1294,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1105
1294
  dbUrl,
1106
1295
  ready: name,
1107
1296
  appEnv,
1297
+ ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
1108
1298
  };
1109
1299
  }