create-ailk 0.32.0 → 0.33.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.
Files changed (46) hide show
  1. package/README.md +9 -3
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/export-content.d.ts +6 -3
  5. package/dist/export-content.js +79 -10
  6. package/dist/lib/import-mdx.d.ts +42 -0
  7. package/dist/lib/import-mdx.js +531 -45
  8. package/dist/seed-flows.d.ts +1 -1
  9. package/dist/seed-flows.js +9 -4
  10. package/dist/surfaces.js +1 -0
  11. package/package.json +3 -3
  12. package/templates/apps/api/CLAUDE.md +7 -5
  13. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  14. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  15. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  16. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  17. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  18. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  19. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  20. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  21. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  22. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  23. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  24. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  25. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  26. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  27. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  28. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  29. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  30. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  31. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  32. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  33. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  34. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  35. package/templates/apps/web/auth.ts +22 -6
  36. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  37. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  38. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  39. package/templates/apps/web/lib/account-config.ts +52 -14
  40. package/templates/apps/web/lib/auth-email.ts +87 -5
  41. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  42. package/templates/apps/web/messages/en.json +26 -1
  43. package/templates/database/seed.ts +7 -7
  44. package/versions.json +48 -48
  45. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  46. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -1,8 +1,10 @@
1
1
  // oss: false
2
2
  /**
3
- * #8771 AC-4 — the walk shows "Track this in your account" with the email the
4
- * visitor gave already filled in, and keeps that email out of every URL it
5
- * navigates to. FlowStepper is a stub that submits a configurable step and
3
+ * #10826 D6 (AC-2) — a walk that completes on a `user` magnet (`next` is
4
+ * `{ completion: "invite" }`) lands on sign-up at once, through the prefilled
5
+ * sign-in link (`window.location.assign`), and writes no `/thanks` hand-off.
6
+ * A `null` link ends the walk as before; a deliverable is never lost to the
7
+ * navigation. FlowStepper is a stub that submits a configurable step and
6
8
  * calls the real `onCompleted` it is given; the Server Actions are doubles.
7
9
  */
8
10
  import { render, screen, waitFor } from "@testing-library/react";
@@ -70,10 +72,18 @@ import { FlowStepperClient } from "../FlowStepperClient";
70
72
  const mockSubmit = submitFlowStepAction as jest.MockedFunction<typeof submitFlowStepAction>;
71
73
  const mockLink = accountInviteLinkAction as jest.MockedFunction<typeof accountInviteLinkAction>;
72
74
 
73
- const HREF = "/sign-in?next=%2Faccount&email=jane%40example.com";
75
+ // jsdom cannot forge `location.assign`, but performs a fragment navigation
76
+ // (the LeadCaptureForm suite's idiom), so the link the action answers is a
77
+ // fragment here and the navigation is read back from `location.hash`.
78
+ const HREF = "#sign-up";
74
79
  const EMAIL = "jane@example.com";
75
80
 
76
- function config(opts: { tagged: boolean; thankYou: boolean; secondEmail?: boolean }): FlowConfig {
81
+ function config(opts: {
82
+ tagged: boolean;
83
+ thankYou: boolean;
84
+ secondEmail?: boolean;
85
+ deliverable?: boolean;
86
+ }): FlowConfig {
77
87
  return {
78
88
  slug: "get-a-quote",
79
89
  defaultLocale: "en",
@@ -106,6 +116,9 @@ function config(opts: { tagged: boolean; thankYou: boolean; secondEmail?: boolea
106
116
  ...(opts.thankYou
107
117
  ? { thankYou: { default: "flow-thanks", bySegment: { qualified: "flow-thanks-qualified" } } }
108
118
  : {}),
119
+ ...(opts.deliverable
120
+ ? { deliverable: { kind: "page", gated: true, href: "/guide", title: { en: "Guide" } } }
121
+ : {}),
109
122
  } as FlowConfig;
110
123
  }
111
124
 
@@ -127,8 +140,11 @@ async function walk(cfg: FlowConfig): Promise<void> {
127
140
  await userEvent.click(screen.getByTestId("mock-submit"));
128
141
  }
129
142
 
143
+ const navigated = () => window.location.hash;
144
+
130
145
  beforeEach(() => {
131
146
  mockPush.mockClear();
147
+ window.location.hash = "";
132
148
  mockSubmit.mockReset();
133
149
  mockLink.mockReset();
134
150
  mockLink.mockResolvedValue(HREF);
@@ -136,20 +152,27 @@ beforeEach(() => {
136
152
  window.sessionStorage.clear();
137
153
  });
138
154
 
139
- describe("the finished panel (no thankYou block)", () => {
140
- it("shows the prefilled link inside a Replay-blocked element", async () => {
155
+ describe("a user magnet lands on sign-up (#10826 D6)", () => {
156
+ it("navigates to the prefilled sign-in link and writes no ailk:account-invite key", async () => {
141
157
  mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
142
158
 
143
159
  await walk(config({ tagged: true, thankYou: false }));
144
160
 
145
- const link = await screen.findByTestId("flow-account-invite");
146
- expect(link).toHaveAttribute("href", HREF);
147
- expect(link).toHaveTextContent("trackInAccount");
148
- expect(link.closest("[data-sentry-block]")).not.toBeNull();
149
- // Inside the finished card, not a sibling after the frame (#8771 design review).
150
- expect(link.closest('[data-testid="mock-completed-card"]')).not.toBeNull();
161
+ await waitFor(() => expect(navigated()).toBe(HREF));
151
162
  expect(mockLink).toHaveBeenCalledTimes(1);
152
163
  expect(mockLink).toHaveBeenCalledWith(EMAIL);
164
+ expect(window.sessionStorage.getItem("ailk:account-invite")).toBeNull();
165
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
166
+ });
167
+
168
+ it("navigates instead of the /thanks redirect on a flow with a thankYou block", async () => {
169
+ mockSubmit.mockResolvedValue(ok({ segment: "qualified", next: { completion: "invite" } }));
170
+
171
+ await walk(config({ tagged: true, thankYou: true }));
172
+
173
+ await waitFor(() => expect(navigated()).toBe(HREF));
174
+ expect(mockPush).not.toHaveBeenCalled();
175
+ expect(window.sessionStorage.length).toBe(0);
153
176
  });
154
177
 
155
178
  it("only an email-tagged field counts", async () => {
@@ -159,7 +182,7 @@ describe("the finished panel (no thankYou block)", () => {
159
182
 
160
183
  await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
161
184
  expect(mockLink).not.toHaveBeenCalled();
162
- expect(screen.queryByTestId("flow-account-invite")).toBeNull();
185
+ expect(navigated()).toBe("");
163
186
  });
164
187
 
165
188
  it("uses the step's first tagged email field", async () => {
@@ -168,31 +191,53 @@ describe("the finished panel (no thankYou block)", () => {
168
191
 
169
192
  await walk(config({ tagged: true, thankYou: false, secondEmail: true }));
170
193
 
171
- await screen.findByTestId("flow-account-invite");
194
+ await waitFor(() => expect(navigated()).toBe(HREF));
172
195
  expect(mockLink).toHaveBeenCalledWith("first@example.com");
173
196
  });
174
197
 
175
198
  it.each([
176
- ["no next", { }],
199
+ ["no next", {}],
177
200
  ["a step that does not complete", { completed: false, next: { completion: "invite" } }],
178
- ])("%s: no action call and no link", async (_name, response) => {
201
+ ])("%s: no action call and no navigation", async (_name, response) => {
179
202
  mockSubmit.mockResolvedValue(ok(response));
180
203
 
181
204
  await walk(config({ tagged: true, thankYou: false }));
182
205
 
183
206
  await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
184
207
  expect(mockLink).not.toHaveBeenCalled();
185
- expect(screen.queryByTestId("flow-account-invite")).toBeNull();
208
+ expect(navigated()).toBe("");
186
209
  });
187
210
 
188
- it("an action answer of null shows no link", async () => {
189
- mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
211
+ it("a null link (no sign-in on this site) ends the walk as before", async () => {
212
+ mockSubmit.mockResolvedValue(ok({ segment: "qualified", next: { completion: "invite" } }));
190
213
  mockLink.mockResolvedValue(null);
191
214
 
192
- await walk(config({ tagged: true, thankYou: false }));
215
+ await walk(config({ tagged: true, thankYou: true }));
193
216
 
194
- await waitFor(() => expect(mockLink).toHaveBeenCalled());
195
- expect(screen.queryByTestId("flow-account-invite")).toBeNull();
217
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/thanks/flow-thanks-qualified"));
218
+ expect(navigated()).toBe("");
219
+ });
220
+
221
+ it("a rejected action ends the walk as before", async () => {
222
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
223
+ mockLink.mockRejectedValue(new Error("boom"));
224
+
225
+ await walk(config({ tagged: true, thankYou: true }));
226
+
227
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/thanks/flow-thanks"));
228
+ expect(navigated()).toBe("");
229
+ });
230
+
231
+ it("a walk with a deliverable never navigates (#8848 D8)", async () => {
232
+ mockSubmit.mockResolvedValue(
233
+ ok({ next: { completion: "invite" }, deliverable: { kind: "page", href: "/guide" } }),
234
+ );
235
+
236
+ await walk(config({ tagged: true, thankYou: false, deliverable: true }));
237
+
238
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
239
+ expect(mockLink).not.toHaveBeenCalled();
240
+ expect(navigated()).toBe("");
196
241
  });
197
242
 
198
243
  it("a conflict forgets the email: a later completing step with no email field asks for no link", async () => {
@@ -210,48 +255,17 @@ describe("the finished panel (no thankYou block)", () => {
210
255
 
211
256
  await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(3));
212
257
  expect(mockLink).not.toHaveBeenCalled();
258
+ expect(navigated()).toBe("");
213
259
  });
214
- });
215
260
 
216
- describe("the /thanks hand-off (a thankYou block)", () => {
217
- it("writes one sessionStorage item and pushes a URL with no email in it", async () => {
218
- mockSubmit.mockResolvedValue(ok({ segment: "qualified", next: { completion: "invite" } }));
261
+ it("a completion with no next pushes /thanks with no email in the URL", async () => {
262
+ mockSubmit.mockResolvedValue(ok({}));
219
263
 
220
264
  await walk(config({ tagged: true, thankYou: true }));
221
265
 
222
266
  await waitFor(() => expect(mockPush).toHaveBeenCalled());
223
- expect(JSON.parse(window.sessionStorage.getItem("ailk:account-invite") ?? "null")).toEqual({
224
- thanks: "flow-thanks-qualified",
225
- href: HREF,
226
- });
227
267
  const url = mockPush.mock.calls[0]?.[0] as string;
228
- expect(url).toBe("/en/thanks/flow-thanks-qualified");
268
+ expect(url).toBe("/en/thanks/flow-thanks");
229
269
  expect(url).not.toContain("@");
230
- expect(screen.queryByTestId("flow-account-invite")).toBeNull();
231
- });
232
-
233
- it("clears an earlier walk's hand-off when this completion has no link", async () => {
234
- window.sessionStorage.setItem(
235
- "ailk:account-invite",
236
- JSON.stringify({ thanks: "flow-thanks", href: HREF }),
237
- );
238
- mockSubmit.mockResolvedValue(ok({}));
239
-
240
- await walk(config({ tagged: true, thankYou: true }));
241
-
242
- await waitFor(() => expect(mockPush).toHaveBeenCalled());
243
- expect(window.sessionStorage.getItem("ailk:account-invite")).toBeNull();
244
- });
245
-
246
- it("a sessionStorage that throws still navigates", async () => {
247
- mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
248
- const spy = jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
249
- throw new Error("denied");
250
- });
251
-
252
- await walk(config({ tagged: true, thankYou: true }));
253
-
254
- await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/thanks/flow-thanks"));
255
- spy.mockRestore();
256
270
  });
257
271
  });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * A magnet Lead's confirm page and action (#10826, spec
3
+ * docs/specs/10826-the-four-conversions-as-completion-spec.md D5; AC-2).
4
+ *
5
+ * The page: `?lead=L&verify=N` renders one confirm button and calls no API
6
+ * while it renders; `?confirmed=1` and `?error=…` render their cards.
7
+ * The action: every bound argument is re-checked before any API call; the
8
+ * limit and the bot check run first; the API's answer picks the redirect,
9
+ * which is always same-site and never carries the nonce.
10
+ */
11
+ import { render, screen } from "@testing-library/react";
12
+
13
+ import en from "../../../../../messages/en.json";
14
+
15
+ const mockRedirect = jest.fn((url: string) => {
16
+ throw new Error(`REDIRECT ${url}`);
17
+ });
18
+ const mockNotFound = jest.fn(() => {
19
+ throw new Error("NOT_FOUND");
20
+ });
21
+ jest.mock("next/navigation", () => ({
22
+ redirect: (url: string) => mockRedirect(url),
23
+ notFound: () => mockNotFound(),
24
+ }));
25
+
26
+ const mockHeaders = jest.fn(
27
+ async () => new Headers({ "x-forwarded-for": "203.0.113.9" }),
28
+ );
29
+ jest.mock("next/headers", () => ({
30
+ headers: () => mockHeaders(),
31
+ }));
32
+
33
+ const mockRunBotCheck = jest.fn();
34
+ // eslint-disable-next-line no-restricted-syntax -- BotID is an external service boundary; lib/bot-check.ts is its one wrapper.
35
+ jest.mock("../../../../../lib/bot-check", () => ({
36
+ runBotCheck: (...args: unknown[]) => mockRunBotCheck(...args),
37
+ }));
38
+
39
+ const mockVerifyLead = jest.fn();
40
+ jest.mock("@working-theory/api-client", () => ({
41
+ flows: { verifyLead: (...args: unknown[]) => mockVerifyLead(...args) },
42
+ }));
43
+
44
+ import { __resetRateLimit } from "../../../../../src/lib/rate-limit";
45
+ import FlowVerifyPage from "../verify/page";
46
+ import { confirmLeadEmailAction } from "../verify.actions";
47
+
48
+ const SLUG = "growth-check";
49
+ const LEAD = "lead_01920000-1082-7000-8000-000000000001";
50
+ const NONCE = "N".repeat(43);
51
+
52
+ async function renderPage(
53
+ query: Record<string, string>,
54
+ slug = SLUG,
55
+ ): Promise<void> {
56
+ render(
57
+ await FlowVerifyPage({
58
+ params: Promise.resolve({ locale: "en", slug }),
59
+ searchParams: Promise.resolve(query),
60
+ }),
61
+ );
62
+ }
63
+
64
+ /** Run the action; answer the redirect URL, asserting it never carries the nonce. */
65
+ async function redirectOf(
66
+ args: [slug?: string, leadId?: string, nonce?: string, locale?: string] = [],
67
+ ): Promise<string> {
68
+ const [slug = SLUG, leadId = LEAD, nonce = NONCE, locale = "en"] = args;
69
+ await expect(
70
+ confirmLeadEmailAction(slug, leadId, nonce, locale),
71
+ ).rejects.toThrow(/^REDIRECT /);
72
+ const url = mockRedirect.mock.calls.at(-1)![0];
73
+ if (nonce.length > 0) expect(url).not.toContain(nonce);
74
+ return url;
75
+ }
76
+
77
+ const answer = (body: Record<string, unknown>) =>
78
+ mockVerifyLead.mockResolvedValue({ ok: true, body });
79
+
80
+ beforeEach(() => {
81
+ jest.clearAllMocks();
82
+ __resetRateLimit();
83
+ mockRunBotCheck.mockResolvedValue("pass");
84
+ answer({ outcome: "verified", landing: null });
85
+ });
86
+
87
+ describe("the confirm page (#10826 D5)", () => {
88
+ it("renders one confirm button on ?lead=&verify= and calls no API while rendering", async () => {
89
+ await renderPage({ lead: LEAD, verify: NONCE });
90
+ expect(screen.getByTestId("flow-verify-confirm")).toBeInTheDocument();
91
+ expect(screen.getByTestId("flow-verify-confirm-button")).toHaveTextContent(
92
+ en.flowVerify.confirmButton,
93
+ );
94
+ expect(mockVerifyLead).not.toHaveBeenCalled();
95
+ expect(mockRedirect).not.toHaveBeenCalled();
96
+ expect(document.body.innerHTML).not.toContain(NONCE);
97
+ });
98
+
99
+ it("renders the confirmed card on ?confirmed=1", async () => {
100
+ await renderPage({ confirmed: "1" });
101
+ expect(screen.getByTestId("flow-verify-confirmed")).toHaveTextContent(
102
+ en.flowVerify.confirmedHeading,
103
+ );
104
+ });
105
+
106
+ it.each([
107
+ ["?error=invalid", { error: "invalid" }],
108
+ ["no query", {}],
109
+ ["a lead id of the wrong shape", { lead: "x", verify: NONCE }],
110
+ ["a 257-character nonce", { lead: LEAD, verify: "N".repeat(257) }],
111
+ ])("renders the error card on %s", async (_label, query) => {
112
+ await renderPage(query);
113
+ expect(screen.getByTestId("flow-verify-error")).toHaveTextContent(
114
+ en.flowVerify.errorHeading,
115
+ );
116
+ expect(mockVerifyLead).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("verify wins over confirmed, and confirmed over error", async () => {
120
+ await renderPage({
121
+ lead: LEAD,
122
+ verify: NONCE,
123
+ confirmed: "1",
124
+ error: "invalid",
125
+ });
126
+ expect(screen.getByTestId("flow-verify-confirm")).toBeInTheDocument();
127
+ });
128
+
129
+ it("is a 404 for an unreadable slug", async () => {
130
+ await expect(
131
+ renderPage({ lead: LEAD, verify: NONCE }, "Bad_Slug"),
132
+ ).rejects.toThrow("NOT_FOUND");
133
+ });
134
+ });
135
+
136
+ describe("confirmLeadEmailAction (#10826 D5)", () => {
137
+ it("posts the bound Lead id and nonce, forwarding the visitor's address", async () => {
138
+ await redirectOf();
139
+ expect(mockVerifyLead).toHaveBeenCalledWith(
140
+ SLUG,
141
+ { leadId: LEAD, nonce: NONCE },
142
+ { headers: { "x-forwarded-for": "203.0.113.9" } },
143
+ );
144
+ });
145
+
146
+ it("redirects a verified lead-link confirm to its landing", async () => {
147
+ answer({ outcome: "verified", landing: "/pricing?plan=growth" });
148
+ expect(await redirectOf()).toBe("/pricing?plan=growth");
149
+ });
150
+
151
+ it("redirects to the confirmed card on already_verified with no landing", async () => {
152
+ answer({ outcome: "already_verified", landing: null });
153
+ expect(await redirectOf()).toBe(`/flows/${SLUG}/verify?confirmed=1`);
154
+ });
155
+
156
+ it("redirects a user magnet's verified Lead to the sign-up landing (accountInviteHref)", async () => {
157
+ answer({
158
+ outcome: "verified",
159
+ landing: null,
160
+ signUpEmail: "a+b@x.example",
161
+ });
162
+ expect(await redirectOf()).toBe(
163
+ "/sign-in?next=%2Faccount&email=a%2Bb%40x.example",
164
+ );
165
+ });
166
+
167
+ it.each([
168
+ "//evil.example",
169
+ "/\\evil.example",
170
+ "https://evil.example",
171
+ "/\t/evil.example",
172
+ "/\n/evil.example",
173
+ "/ok path",
174
+ "/ok\\..\\evil",
175
+ ])("never redirects to a landing of %s (SEC-10826-5)", async (landing) => {
176
+ answer({ outcome: "verified", landing });
177
+ expect(await redirectOf()).toBe(`/flows/${SLUG}/verify?confirmed=1`);
178
+ });
179
+
180
+ it.each([
181
+ ["expired", { ok: true, body: { outcome: "expired", landing: null } }],
182
+ ["invalid", { ok: true, body: { outcome: "invalid", landing: null } }],
183
+ ["a non-2xx", { ok: false, status: 503 }],
184
+ ])("redirects to the error card on %s", async (_label, res) => {
185
+ mockVerifyLead.mockResolvedValue(res);
186
+ expect(await redirectOf()).toBe(`/flows/${SLUG}/verify?error=invalid`);
187
+ });
188
+
189
+ it("redirects to the error card when the call throws", async () => {
190
+ mockVerifyLead.mockRejectedValue(new Error("boom"));
191
+ expect(await redirectOf()).toBe(`/flows/${SLUG}/verify?error=invalid`);
192
+ });
193
+
194
+ it.each(["bot", "unavailable"])(
195
+ "makes no API call when the bot check answers %s",
196
+ async (verdict) => {
197
+ mockRunBotCheck.mockResolvedValue(verdict);
198
+ expect(await redirectOf()).toBe(`/flows/${SLUG}/verify?error=invalid`);
199
+ expect(mockVerifyLead).not.toHaveBeenCalled();
200
+ },
201
+ );
202
+
203
+ it("makes no API call once the visitor's limit (5 per 10 minutes) is spent", async () => {
204
+ const vercel = process.env.VERCEL;
205
+ process.env.VERCEL = "1";
206
+ mockHeaders.mockImplementation(
207
+ async () => new Headers({ "x-vercel-forwarded-for": "203.0.113.9" }),
208
+ );
209
+ try {
210
+ for (let i = 0; i < 5; i++) await redirectOf();
211
+ mockVerifyLead.mockClear();
212
+ expect(await redirectOf()).toBe(`/flows/${SLUG}/verify?error=invalid`);
213
+ expect(mockVerifyLead).not.toHaveBeenCalled();
214
+ } finally {
215
+ if (vercel === undefined) delete process.env.VERCEL;
216
+ else process.env.VERCEL = vercel;
217
+ mockHeaders.mockImplementation(
218
+ async () => new Headers({ "x-forwarded-for": "203.0.113.9" }),
219
+ );
220
+ }
221
+ });
222
+
223
+ it.each([
224
+ ["a traversal slug", ["x/../../v1/other"], "/?error=invalid"],
225
+ [
226
+ "a locale that is not enabled",
227
+ [SLUG, LEAD, NONCE, "/evil.example"],
228
+ `/flows/${SLUG}/verify?error=invalid`,
229
+ ],
230
+ [
231
+ "a 257-character nonce",
232
+ [SLUG, LEAD, "N".repeat(257)],
233
+ `/flows/${SLUG}/verify?error=invalid`,
234
+ ],
235
+ ["an empty nonce", [SLUG, LEAD, ""], `/flows/${SLUG}/verify?error=invalid`],
236
+ [
237
+ "a Lead id of the wrong shape",
238
+ [SLUG, "lead_x"],
239
+ `/flows/${SLUG}/verify?error=invalid`,
240
+ ],
241
+ ] as const)(
242
+ "refuses %s with no API call (SEC-10826-6)",
243
+ async (_label, args, href) => {
244
+ expect(await redirectOf([...args])).toBe(href);
245
+ expect(mockVerifyLead).not.toHaveBeenCalled();
246
+ expect(mockRunBotCheck).not.toHaveBeenCalled();
247
+ },
248
+ );
249
+ });