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.
- package/README.md +9 -3
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +531 -45
- package/dist/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- package/dist/surfaces.js +1 -0
- package/package.json +3 -3
- package/templates/apps/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
- package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
- package/templates/apps/web/auth.ts +22 -6
- package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
- package/templates/apps/web/lib/account-config.ts +52 -14
- package/templates/apps/web/lib/auth-email.ts +87 -5
- package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
- package/templates/apps/web/messages/en.json +26 -1
- package/templates/database/seed.ts +7 -7
- package/versions.json +48 -48
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// oss: false
|
|
2
2
|
/**
|
|
3
|
-
* #
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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: {
|
|
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("
|
|
140
|
-
it("
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
208
|
+
expect(navigated()).toBe("");
|
|
186
209
|
});
|
|
187
210
|
|
|
188
|
-
it("
|
|
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:
|
|
215
|
+
await walk(config({ tagged: true, thankYou: true }));
|
|
193
216
|
|
|
194
|
-
await waitFor(() => expect(
|
|
195
|
-
expect(
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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
|
+
});
|