diff --git a/.github/actions/console-web-ui-testing/action.yml b/.github/actions/console-web-ui-testing/action.yml index 545f76cd11..b64f8fd14b 100644 --- a/.github/actions/console-web-ui-testing/action.yml +++ b/.github/actions/console-web-ui-testing/action.yml @@ -27,13 +27,15 @@ inputs: auth0-m2m-client-secret: description: Auth0 M2M client secret required: true - mailsac-api-key: - description: Mailsac API key for email verification + e2e-inbox-api-url: + description: Base URL of the e2e inbox worker that captures OTP emails (see https://github.com/akash-network/e2e-inbox-worker) + required: true + e2e-inbox-api-token: + description: Bearer token for the e2e inbox worker HTTP endpoint + required: true + e2e-inbox-email-domain: + description: Email domain routed to the e2e inbox worker (dedicated domain, e.g. e2e.example.com) required: true - email-verification-strategy: - description: Email verification strategy (mailsac-code or auth0-ticket) - required: false - default: "mailsac-code" test-user-email: description: Pre-registered test user email for authenticated tests required: false @@ -63,8 +65,9 @@ runs: AUTH0_M2M_DOMAIN: ${{ inputs.auth0-m2m-domain }} AUTH0_M2M_CLIENT_ID: ${{ inputs.auth0-m2m-client-id }} AUTH0_M2M_CLIENT_SECRET: ${{ inputs.auth0-m2m-client-secret }} - MAILSAC_API_KEY: ${{ inputs.mailsac-api-key }} - EMAIL_VERIFICATION_STRATEGY: ${{ inputs.email-verification-strategy }} + E2E_INBOX_API_URL: ${{ inputs.e2e-inbox-api-url }} + E2E_INBOX_API_TOKEN: ${{ inputs.e2e-inbox-api-token }} + E2E_INBOX_EMAIL_DOMAIN: ${{ inputs.e2e-inbox-email-domain }} TEST_USER_EMAIL: ${{ inputs.test-user-email }} TEST_USER_PASSWORD: ${{ inputs.test-user-password }} USER_DATA_DIR: ${{ runner.temp }}/chrome-profile-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/reusable-deploy-test.yml b/.github/workflows/reusable-deploy-test.yml index 6d708f3373..801ed7a02b 100644 --- a/.github/workflows/reusable-deploy-test.yml +++ b/.github/workflows/reusable-deploy-test.yml @@ -82,7 +82,8 @@ jobs: auth0-m2m-domain: ${{ secrets.AUTH0_M2M_DOMAIN }} auth0-m2m-client-id: ${{ secrets.AUTH0_M2M_CLIENT_ID }} auth0-m2m-client-secret: ${{ secrets.AUTH0_M2M_CLIENT_SECRET }} - mailsac-api-key: ${{ secrets.MAILSAC_API_KEY }} - email-verification-strategy: ${{ vars.EMAIL_VERIFICATION_STRATEGY || 'mailsac-code' }} + e2e-inbox-api-url: ${{ vars.E2E_INBOX_API_URL }} + e2e-inbox-api-token: ${{ secrets.E2E_INBOX_API_TOKEN }} + e2e-inbox-email-domain: ${{ vars.E2E_INBOX_EMAIL_DOMAIN }} test-user-email: ${{ secrets.CONSOLE_WEB_E2E_TEST_USER_EMAIL }} test-user-password: ${{ secrets.CONSOLE_WEB_E2E_TEST_USER_PASSWORD }} diff --git a/apps/deploy-web/playwright.config.ts b/apps/deploy-web/playwright.config.ts index f5e368f77b..cdc8a88b44 100644 --- a/apps/deploy-web/playwright.config.ts +++ b/apps/deploy-web/playwright.config.ts @@ -18,6 +18,8 @@ const RECORDING_VIEWPORT = { width: 1728, height: 1080 }; */ export default defineConfig({ testDir: "./tests/ui", + /* Vitest owns *.unit.spec.ts files colocated in tests/ui (see vitest.config.ts "unit-e2e-helpers" project). */ + testIgnore: "**/*.unit.spec.ts", fullyParallel: false, workers: 1, forbidOnly: !!process.env.CI, diff --git a/apps/deploy-web/tests/ui/actions/auth.ts b/apps/deploy-web/tests/ui/actions/auth.ts index 3a9a9d5678..0c8bd60e4a 100644 --- a/apps/deploy-web/tests/ui/actions/auth.ts +++ b/apps/deploy-web/tests/ui/actions/auth.ts @@ -6,7 +6,7 @@ import { AuthPage } from "../pages/AuthPage"; import { AuthPagePasswordless } from "../pages/AuthPagePasswordless"; import type { Auth0ManagementService } from "../services/auth0-management.service"; import type { EmailVerificationStrategy } from "../services/email-verification"; -import { MailsacCodeVerificationStrategy } from "../services/email-verification/mailsac-code.strategy"; +import { createEmailVerificationStrategy } from "../services/email-verification"; /** Which credential mechanism a flow authenticates with. */ export type AuthType = "passwordless" | "email-password"; @@ -86,7 +86,7 @@ export async function registerNewUser( } async function registerPasswordless(page: Page): Promise { - const otp = new MailsacCodeVerificationStrategy(testEnvConfig.MAILSAC_API_KEY); + const otp = createEmailVerificationStrategy(); const email = otp.generateEmail(); await signInPasswordless(page, email); return email; @@ -124,9 +124,9 @@ async function signInWithPassword(page: Page, credentials: { email: string; pass await auth.signIn(credentials); } -/** Drives the passwordless (email OTP via Mailsac) flow for the given email. Page is expected to be on /login. */ +/** Drives the passwordless (email OTP read from the e2e inbox worker) flow for the given email. Page is expected to be on /login. */ export async function signInPasswordless(page: Page, email: string): Promise { - const otp = new MailsacCodeVerificationStrategy(testEnvConfig.MAILSAC_API_KEY); + const otp = createEmailVerificationStrategy(); const auth = new AuthPagePasswordless(page); const sinceMs = Date.now(); diff --git a/apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.ts b/apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.ts new file mode 100644 index 0000000000..5b371705bb --- /dev/null +++ b/apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +/** http is tolerated only for these local-dev hosts; every deployed inbox worker URL must be https. */ +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** + * The bearer token in E2E_INBOX_API_TOKEN is sent to whatever host this resolves to, so the URL is + * constrained to an absolute https endpoint (or plain http on loopback for local dev) rather than any + * non-empty string, which turns a mistyped/insecure value into a clear config error up front. + */ +export const inboxApiUrlSchema = z + .string({ required_error: "Base URL of the e2e inbox worker that captures OTP emails (see https://github.com/akash-network/e2e-inbox-worker)" }) + .trim() + .min(1) + .transform(url => url.replace(/\/+$/, "")) + .superRefine((url, ctx) => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `E2E_INBOX_API_URL must be an absolute URL, got "${url}"` }); + return; + } + + const isLoopbackHttp = parsed.protocol === "http:" && LOOPBACK_HOSTNAMES.has(parsed.hostname); + if (parsed.protocol !== "https:" && !isLoopbackHttp) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `E2E_INBOX_API_URL must use https (http allowed only for loopback hosts), got "${parsed.protocol}//${parsed.host}"` + }); + } + }); diff --git a/apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.unit.spec.ts b/apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.unit.spec.ts new file mode 100644 index 0000000000..e7da2f64ac --- /dev/null +++ b/apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.unit.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { inboxApiUrlSchema } from "./inbox-api-url.schema"; + +describe("inboxApiUrlSchema", () => { + it("accepts an https URL and strips trailing slashes", () => { + expect(inboxApiUrlSchema.parse("https://console-e2e-inbox.example.workers.dev/")).toBe("https://console-e2e-inbox.example.workers.dev"); + }); + + it("trims surrounding whitespace before validating", () => { + expect(inboxApiUrlSchema.parse(" https://inbox.test ")).toBe("https://inbox.test"); + }); + + it("accepts http only for loopback hosts used in local development", () => { + expect(inboxApiUrlSchema.parse("http://localhost:8787")).toBe("http://localhost:8787"); + expect(inboxApiUrlSchema.parse("http://127.0.0.1:8787")).toBe("http://127.0.0.1:8787"); + expect(inboxApiUrlSchema.parse("http://[::1]:8787")).toBe("http://[::1]:8787"); + }); + + it("rejects http on a non-loopback host", () => { + expect(() => inboxApiUrlSchema.parse("http://inbox.example.com")).toThrow(/must use https/); + }); + + it("rejects non-http schemes", () => { + expect(() => inboxApiUrlSchema.parse("ftp://inbox.example.com")).toThrow(/must use https/); + }); + + it("rejects a relative or malformed URL", () => { + expect(() => inboxApiUrlSchema.parse("/messages")).toThrow(/must be an absolute URL/); + }); + + it("rejects an empty value", () => { + expect(() => inboxApiUrlSchema.parse("")).toThrow(); + }); +}); diff --git a/apps/deploy-web/tests/ui/fixture/test-env.config.ts b/apps/deploy-web/tests/ui/fixture/test-env.config.ts index 5b3b7075c4..a43a7d12d8 100644 --- a/apps/deploy-web/tests/ui/fixture/test-env.config.ts +++ b/apps/deploy-web/tests/ui/fixture/test-env.config.ts @@ -2,6 +2,8 @@ import { tmpdir } from "os"; import path from "path"; import { z } from "zod"; +import { inboxApiUrlSchema } from "./inbox-api-url.schema"; + export const testEnvSchema = z.object({ BASE_URL: z .string() @@ -15,7 +17,9 @@ export const testEnvSchema = z.object({ AUTH0_M2M_DOMAIN: z.string({ required_error: "Auth0 M2M domain for management API calls (e.g. 'your-tenant.us.auth0.com')" }).trim().min(1), AUTH0_M2M_CLIENT_ID: z.string({ required_error: "Auth0 M2M client ID for management API" }).trim().min(1), AUTH0_M2M_CLIENT_SECRET: z.string({ required_error: "Auth0 M2M client secret for management API" }).trim().min(1), - MAILSAC_API_KEY: z.string({ required_error: "Mailsac API key for email verification" }).trim().min(1), + E2E_INBOX_API_URL: inboxApiUrlSchema, + E2E_INBOX_API_TOKEN: z.string({ required_error: "Bearer token for the e2e inbox worker HTTP endpoint" }).trim().min(1), + E2E_INBOX_EMAIL_DOMAIN: z.string({ required_error: "Email domain routed to the e2e inbox worker (dedicated domain, e.g. 'e2e.example.com')" }).trim().min(1), TEST_USER_EMAIL: z.string().optional(), TEST_USER_PASSWORD: z.string().optional() }); @@ -28,7 +32,9 @@ export const testEnvConfig = testEnvSchema.parse({ AUTH0_M2M_DOMAIN: process.env.AUTH0_M2M_DOMAIN, AUTH0_M2M_CLIENT_ID: process.env.AUTH0_M2M_CLIENT_ID, AUTH0_M2M_CLIENT_SECRET: process.env.AUTH0_M2M_CLIENT_SECRET, - MAILSAC_API_KEY: process.env.MAILSAC_API_KEY, + E2E_INBOX_API_URL: process.env.E2E_INBOX_API_URL, + E2E_INBOX_API_TOKEN: process.env.E2E_INBOX_API_TOKEN, + E2E_INBOX_EMAIL_DOMAIN: process.env.E2E_INBOX_EMAIL_DOMAIN, TEST_USER_EMAIL: process.env.TEST_USER_EMAIL, TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD }); diff --git a/apps/deploy-web/tests/ui/onboarding-journey.spec.ts b/apps/deploy-web/tests/ui/onboarding-journey.spec.ts index 4d8410b077..462df2e5a4 100644 --- a/apps/deploy-web/tests/ui/onboarding-journey.spec.ts +++ b/apps/deploy-web/tests/ui/onboarding-journey.spec.ts @@ -9,7 +9,7 @@ import { OnboardingPickerPage } from "./pages/OnboardingPickerPage"; /** * The real new-user onboarding journeys, driven the way a user drives them: the fixture registers a fresh user - * through the live /login UI (email OTP via Mailsac); from there the test only clicks — it never deep-links to a + * through the live /login UI (email OTP read from the e2e inbox worker); from there the test only clicks — it never deep-links to a * functional page. Two entry points off the onboarding picker: a one-click template auto-deploy, and the "bring * your own Docker image" path that lands in the configure screen for a manual deploy. Both end with the user * onboarded (and no longer sent to onboarding), closing the deployment so the run leaves no live state behind. diff --git a/apps/deploy-web/tests/ui/passwordless-login.spec.ts b/apps/deploy-web/tests/ui/passwordless-login.spec.ts index 8601f1c9b8..9e311cd397 100644 --- a/apps/deploy-web/tests/ui/passwordless-login.spec.ts +++ b/apps/deploy-web/tests/ui/passwordless-login.spec.ts @@ -6,11 +6,11 @@ import { testEnvConfig } from "./fixture/test-env.config"; import { AuthPagePasswordless } from "./pages/AuthPagePasswordless"; import { HomePage } from "./pages/HomePage"; import { OnboardingPage } from "./pages/OnboardingPage"; -import { MailsacCodeVerificationStrategy } from "./services/email-verification/mailsac-code.strategy"; +import { createEmailVerificationStrategy } from "./services/email-verification"; test.describe("Passwordless auth", () => { let createdUserId: string | undefined; - const otp = new MailsacCodeVerificationStrategy(testEnvConfig.MAILSAC_API_KEY); + const otp = createEmailVerificationStrategy(); test.afterEach(async ({ auth0 }) => { if (!createdUserId) return; diff --git a/apps/deploy-web/tests/ui/services/email-verification/email-verification.strategy.ts b/apps/deploy-web/tests/ui/services/email-verification/email-verification.strategy.ts index d7d035881a..7c47d8b31e 100644 --- a/apps/deploy-web/tests/ui/services/email-verification/email-verification.strategy.ts +++ b/apps/deploy-web/tests/ui/services/email-verification/email-verification.strategy.ts @@ -5,7 +5,7 @@ export interface EmailVerificationStrategy { /** * `sinceMs` is the epoch millisecond captured before the action that triggers the verification email * (signup click, passwordless start, etc.). Mailbox-polling strategies use it to ignore stale - * messages; strategies that don't poll mailboxes (e.g., Auth0 ticket) ignore it. + * messages; strategies that don't poll mailboxes ignore it. */ verify(input: { context: BrowserContext; email: string; userId: string; sinceMs: number }): Promise; } diff --git a/apps/deploy-web/tests/ui/services/email-verification/inbox-client.ts b/apps/deploy-web/tests/ui/services/email-verification/inbox-client.ts new file mode 100644 index 0000000000..4ac66e5070 --- /dev/null +++ b/apps/deploy-web/tests/ui/services/email-verification/inbox-client.ts @@ -0,0 +1,11 @@ +export interface InboxMessage { + id: string; + receivedMs: number; + subject?: string; +} + +export interface InboxClient { + generateEmail(): string; + fetchMessages(email: string): Promise; + fetchMessageBody(email: string, messageId: string): Promise; +} diff --git a/apps/deploy-web/tests/ui/services/email-verification/mailsac-code.strategy.ts b/apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.ts similarity index 74% rename from apps/deploy-web/tests/ui/services/email-verification/mailsac-code.strategy.ts rename to apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.ts index 288be9cf0d..328b9d50c2 100644 --- a/apps/deploy-web/tests/ui/services/email-verification/mailsac-code.strategy.ts +++ b/apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.ts @@ -1,6 +1,7 @@ import type { BrowserContext, Page } from "@playwright/test"; import type { EmailVerificationStrategy } from "./email-verification.strategy"; +import type { InboxClient, InboxMessage } from "./inbox-client"; /** * Match a 6-digit number that appears within ~200 chars after the word "code" @@ -31,12 +32,6 @@ const INPUT_READY_TIMEOUT_MS = 5_000; */ const NEXT_ROUTE_ANNOUNCER_SELECTOR = "#__next-route-announcer__"; -interface MailsacMessage { - _id: string; - subject?: string; - received?: string; -} - interface CodeCandidate { messageId: string; code: string; @@ -49,20 +44,19 @@ interface CodeAttemptFailure { type SubmitOutcome = { kind: "success" } | { kind: "rejected"; message: string }; -export class MailsacCodeVerificationStrategy implements EmailVerificationStrategy { - readonly #baseUrl = "https://mailsac.com/api"; - readonly #apiKey: string; +export class InboxCodeVerificationStrategy implements EmailVerificationStrategy { + readonly #inbox: InboxClient; - constructor(apiKey: string) { - this.#apiKey = apiKey; + constructor(inbox: InboxClient) { + this.#inbox = inbox; } generateEmail(): string { - return `e2e-${crypto.randomUUID().slice(0, 8)}@mailsac.com`; + return this.#inbox.generateEmail(); } /** - * Polls Mailsac for fresh OTP codes (received after this call started) and tries each + * Polls the inbox for fresh OTP codes (received after this call started) and tries each * in turn. Codes that the server rejects are remembered so we don't try them again. * Returns once the form submission succeeds (page leaves /login); throws once polling * exhausts without finding a working code. @@ -73,7 +67,7 @@ export class MailsacCodeVerificationStrategy implements EmailVerificationStrateg const scannedWithoutCode = new Set(); const failures: CodeAttemptFailure[] = []; - for (;;) { + while (true) { let candidate: CodeCandidate; try { candidate = await this.#pollForNextCode(input.email, input.sinceMs, triedMessageIds, scannedWithoutCode); @@ -102,18 +96,18 @@ export class MailsacCodeVerificationStrategy implements EmailVerificationStrateg const deadline = Date.now() + POLL_DEADLINE_MS; let interval = POLL_INITIAL_INTERVAL_MS; - for (;;) { + while (true) { try { - const messages = await this.#fetchMessages(email); + const messages = await this.#inbox.fetchMessages(email); for (const message of messagesNewestFirst(messages)) { - if (excludeMessageIds.has(message._id) || scannedWithoutCode.has(message._id)) continue; - if (!isMessageFreshSince(message, freshAfterMs)) continue; - const body = await this.#fetchMessageBody(email, message._id); + if (excludeMessageIds.has(message.id) || scannedWithoutCode.has(message.id)) continue; + if (message.receivedMs < freshAfterMs) continue; + const body = await this.#inbox.fetchMessageBody(email, message.id); const code = body.match(CODE_NEAR_KEYWORD)?.[1]; if (code) { - return { messageId: message._id, code }; + return { messageId: message.id, code }; } - scannedWithoutCode.add(message._id); + scannedWithoutCode.add(message.id); } } catch (error) { pollErrors.push(error instanceof Error ? error : new Error(String(error))); @@ -180,48 +174,14 @@ export class MailsacCodeVerificationStrategy implements EmailVerificationStrateg } return new Error(`${summary}\nPolling: ${pollError instanceof Error ? pollError.message : String(pollError)}`); } - - #fetchMessages(email: string): Promise { - return this.#fetch(`${this.#baseUrl}/addresses/${email}/messages`); - } - - async #fetchMessageBody(email: string, messageId: string): Promise { - const response = await fetch(`${this.#baseUrl}/text/${email}/${messageId}`, { - headers: { "Mailsac-Key": this.#apiKey } - }); - if (!response.ok) { - throw new Error(`Mailsac body fetch failed (${response.status}): ${await response.text()}`); - } - return response.text(); - } - - async #fetch(path: string): Promise { - const response = await fetch(path, { - headers: { "Mailsac-Key": this.#apiKey } - }); - if (!response.ok) { - throw new Error(`Mailsac request failed (${response.status}): ${await response.text()}`); - } - return response.json(); - } -} - -function isMessageFreshSince(message: MailsacMessage, freshAfterMs: number): boolean { - return receivedMs(message) >= freshAfterMs; } /** * Sorts a copy newest-first so the most recent (and most likely OTP) message is scanned * before older ones — the working code is then found in the fewest body fetches. */ -function messagesNewestFirst(messages: MailsacMessage[]): MailsacMessage[] { - return [...messages].sort((a, b) => receivedMs(b) - receivedMs(a)); -} - -/** Parsed `received` timestamp in ms, or -Infinity when missing/unparseable so it sorts last. */ -function receivedMs(message: MailsacMessage): number { - const ms = message.received ? Date.parse(message.received) : NaN; - return Number.isNaN(ms) ? -Infinity : ms; +function messagesNewestFirst(messages: InboxMessage[]): InboxMessage[] { + return [...messages].sort((a, b) => b.receivedMs - a.receivedMs); } function sleep(ms: number): Promise { diff --git a/apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.unit.spec.ts b/apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.unit.spec.ts new file mode 100644 index 0000000000..5bdf08e256 --- /dev/null +++ b/apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.unit.spec.ts @@ -0,0 +1,135 @@ +import type { BrowserContext, Locator, Page } from "@playwright/test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mock, mockDeep } from "vitest-mock-extended"; + +import type { InboxClient, InboxMessage } from "./inbox-client"; +import { InboxCodeVerificationStrategy } from "./inbox-code.strategy"; + +const SINCE_MS = 1_000_000; + +describe(InboxCodeVerificationStrategy.name, () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("delegates email generation to the inbox client", () => { + const { strategy, inbox } = setup(); + inbox.generateEmail.mockReturnValue("e2e-abc@e2e.example.test"); + + expect(strategy.generateEmail()).toBe("e2e-abc@e2e.example.test"); + }); + + it("submits the first fresh code and resolves when the page leaves /login", async () => { + const { strategy, context, page } = setup({ + messages: [{ id: "m1", receivedMs: SINCE_MS + 1_000 }], + bodies: { m1: "Your verification code is: 123456" }, + navigationAway: [Promise.resolve()] + }); + + await strategy.verify({ context, email: "probe@e2e.example.test", userId: "", sinceMs: SINCE_MS }); + + expect(page.keyboard.type).toHaveBeenCalledWith("123456"); + }); + + it("ignores messages received before the flow started", async () => { + const { strategy, context, inbox, page } = setup({ + messages: [ + { id: "stale", receivedMs: SINCE_MS - 1 }, + { id: "fresh", receivedMs: SINCE_MS + 1 } + ], + bodies: { fresh: "Your verification code is: 654321" }, + navigationAway: [Promise.resolve()] + }); + + await strategy.verify({ context, email: "probe@e2e.example.test", userId: "", sinceMs: SINCE_MS }); + + expect(inbox.fetchMessageBody).not.toHaveBeenCalledWith("probe@e2e.example.test", "stale"); + expect(page.keyboard.type).toHaveBeenCalledWith("654321"); + }); + + it("tries the next code after the server rejects the newest one", async () => { + const { strategy, context, page, errorAlert } = setup({ + messages: [ + { id: "older", receivedMs: SINCE_MS + 1_000 }, + { id: "newest", receivedMs: SINCE_MS + 2_000 } + ], + bodies: { + newest: "Your verification code is: 222222", + older: "Your verification code is: 111111" + }, + navigationAway: [pending(), Promise.resolve()], + alertShown: [Promise.resolve(), pending()] + }); + errorAlert.textContent.mockResolvedValue("Wrong code"); + + await strategy.verify({ context, email: "probe@e2e.example.test", userId: "", sinceMs: SINCE_MS }); + + expect(page.keyboard.type).toHaveBeenNthCalledWith(1, "222222"); + expect(page.keyboard.type).toHaveBeenNthCalledWith(2, "111111"); + }); + + it("fails with the polling summary when no fresh code arrives before the deadline", async () => { + vi.useFakeTimers(); + const { strategy, context } = setup({ messages: [] }); + + const outcome = expect(strategy.verify({ context, email: "probe@e2e.example.test", userId: "", sinceMs: SINCE_MS })).rejects.toThrow( + /No working verification code arrived for probe@e2e\.example\.test/ + ); + await vi.advanceTimersByTimeAsync(61_000); + + await outcome; + }); + + it("throws when the browser context has no pages", async () => { + const { strategy } = setup(); + const context = mock(); + context.pages.mockReturnValue([]); + + await expect(strategy.verify({ context, email: "probe@e2e.example.test", userId: "", sinceMs: SINCE_MS })).rejects.toThrow("No browser pages available"); + }); + + function setup(input?: { + messages?: InboxMessage[]; + bodies?: Record; + /** One entry per submit attempt: resolve = page left /login, pending() = still on /login. */ + navigationAway?: Array>; + /** One entry per submit attempt: resolve = rejection alert appeared, pending() = no alert. */ + alertShown?: Array>; + }) { + const inbox = mock(); + inbox.fetchMessages.mockResolvedValue(input?.messages ?? []); + inbox.fetchMessageBody.mockImplementation(async (_email, messageId) => { + const body = input?.bodies?.[messageId]; + if (body === undefined) throw new Error(`unexpected body fetch for ${messageId}`); + return body; + }); + + const firstDigitInput = mock(); + const errorAlert = mock(); + errorAlert.filter.mockReturnValue(errorAlert); + errorAlert.first.mockReturnValue(errorAlert); + for (const alertOutcome of input?.alertShown ?? []) { + errorAlert.waitFor.mockReturnValueOnce(alertOutcome); + } + errorAlert.waitFor.mockReturnValue(pending()); + + const page = mockDeep(); + page.getByLabel.mockReturnValue(firstDigitInput); + page.locator.mockReturnValue(errorAlert); + page.url.mockReturnValue("https://app.test/login"); + for (const navigationOutcome of input?.navigationAway ?? []) { + page.waitForURL.mockReturnValueOnce(navigationOutcome); + } + page.waitForURL.mockReturnValue(pending()); + + const context = mock(); + context.pages.mockReturnValue([page]); + + const strategy = new InboxCodeVerificationStrategy(inbox); + return { strategy, inbox, context, page, errorAlert, firstDigitInput }; + } + + function pending(): Promise { + return new Promise(() => {}); + } +}); diff --git a/apps/deploy-web/tests/ui/services/email-verification/index.ts b/apps/deploy-web/tests/ui/services/email-verification/index.ts index 419ed98492..07ddc21175 100644 --- a/apps/deploy-web/tests/ui/services/email-verification/index.ts +++ b/apps/deploy-web/tests/ui/services/email-verification/index.ts @@ -1,14 +1,21 @@ import { testEnvConfig } from "../../fixture/test-env.config"; import type { EmailVerificationStrategy } from "./email-verification.strategy"; -import { MailsacCodeVerificationStrategy } from "./mailsac-code.strategy"; +import { InboxCodeVerificationStrategy } from "./inbox-code.strategy"; +import { WorkerInboxClient } from "./worker-inbox.client"; export type { EmailVerificationStrategy } from "./email-verification.strategy"; /** - * The onboarding user verifies email through the real Mailsac inbox (OTP code), matching the passwordless - * signup flow. The prior `auth0-ticket` bypass has been removed so the onboarding journey always exercises - * real email delivery end-to-end. + * Email verification reads real Auth0 OTP emails through the self-hosted inbox worker + * (https://github.com/akash-network/e2e-inbox-worker), so signup and passwordless flows exercise real email delivery + * end-to-end without depending on a third-party inbox service and its quotas. */ export function createEmailVerificationStrategy(): EmailVerificationStrategy { - return new MailsacCodeVerificationStrategy(testEnvConfig.MAILSAC_API_KEY); + return new InboxCodeVerificationStrategy( + new WorkerInboxClient({ + apiUrl: testEnvConfig.E2E_INBOX_API_URL, + apiToken: testEnvConfig.E2E_INBOX_API_TOKEN, + emailDomain: testEnvConfig.E2E_INBOX_EMAIL_DOMAIN + }) + ); } diff --git a/apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.ts b/apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.ts new file mode 100644 index 0000000000..4168bab38b --- /dev/null +++ b/apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.ts @@ -0,0 +1,64 @@ +import type { InboxClient, InboxMessage } from "./inbox-client"; + +interface WorkerInboxMessage { + id: string; + receivedMs: number; + subject: string; + text: string; +} + +/** Cap each inbox read so a stalled worker connection aborts and lets InboxCodeStrategy retry within its poll deadline. */ +const REQUEST_TIMEOUT_MS = 10_000; + +/** + * Inbox backed by the self-hosted Cloudflare Email Worker (https://github.com/akash-network/e2e-inbox-worker): a catch-all + * rule on the e2e email domain stores incoming mail in D1, and this client reads it back through + * the worker's token-protected HTTP endpoint. The endpoint returns message bodies inline, so + * `fetchMessageBody` serves from the payload cached by the last `fetchMessages` call. + */ +export class WorkerInboxClient implements InboxClient { + readonly #apiUrl: string; + readonly #apiToken: string; + readonly #emailDomain: string; + readonly #bodiesByMessageId = new Map(); + + constructor(config: { apiUrl: string; apiToken: string; emailDomain: string }) { + this.#apiUrl = config.apiUrl; + this.#apiToken = config.apiToken; + this.#emailDomain = config.emailDomain; + } + + generateEmail(): string { + return `e2e-${crypto.randomUUID().slice(0, 8)}@${this.#emailDomain}`; + } + + async fetchMessages(email: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(`${this.#apiUrl}/messages/${encodeURIComponent(email)}`, { + headers: { Authorization: `Bearer ${this.#apiToken}` }, + signal: controller.signal + }); + if (!response.ok) { + throw new Error(`Inbox worker request failed (${response.status}): ${await response.text()}`); + } + + const messages: WorkerInboxMessage[] = await response.json(); + for (const message of messages) { + this.#bodiesByMessageId.set(message.id, message.text); + } + return messages.map(({ id, receivedMs, subject }) => ({ id, receivedMs, subject })); + } finally { + clearTimeout(timeout); + } + } + + async fetchMessageBody(email: string, messageId: string): Promise { + const body = this.#bodiesByMessageId.get(messageId); + if (body === undefined) { + throw new Error(`Inbox worker returned no body for message ${messageId} of ${email}; call fetchMessages first`); + } + return body; + } +} diff --git a/apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.unit.spec.ts b/apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.unit.spec.ts new file mode 100644 index 0000000000..7061c2e605 --- /dev/null +++ b/apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.unit.spec.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { WorkerInboxClient } from "./worker-inbox.client"; + +describe(WorkerInboxClient.name, () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("generateEmail", () => { + it("generates a unique address on the configured domain", () => { + const { client } = setup(); + + expect(client.generateEmail()).toMatch(/^e2e-[0-9a-f]{8}@e2e\.example\.test$/); + }); + }); + + describe("fetchMessages", () => { + it("requests the encoded recipient with the bearer token and maps message metadata", async () => { + const { client, fetchMock } = setup({ messages: [{ id: "m1", receivedMs: 123, subject: "Hi", text: "code 111111" }] }); + + const messages = await client.fetchMessages("probe@e2e.example.test"); + + expect(fetchMock).toHaveBeenCalledWith("https://inbox.test/messages/probe%40e2e.example.test", { + headers: { Authorization: "Bearer secret" }, + signal: expect.any(AbortSignal) + }); + expect(messages).toEqual([{ id: "m1", receivedMs: 123, subject: "Hi" }]); + }); + + it("throws with status and body when the worker responds with an error", async () => { + const { client } = setup({ response: new Response("nope", { status: 401 }) }); + + await expect(client.fetchMessages("probe@e2e.example.test")).rejects.toThrow("Inbox worker request failed (401): nope"); + }); + + it("aborts the request when the worker does not respond within the timeout", async () => { + vi.useFakeTimers(); + const { client } = setup({ hangsUntilAborted: true }); + + const pending = client.fetchMessages("probe@e2e.example.test"); + const assertion = expect(pending).rejects.toThrow(/aborted/i); + await vi.advanceTimersByTimeAsync(10_000); + + await assertion; + vi.useRealTimers(); + }); + }); + + describe("fetchMessageBody", () => { + it("serves the body cached by the last fetchMessages call without another request", async () => { + const { client, fetchMock } = setup({ messages: [{ id: "m1", receivedMs: 1, subject: "Hi", text: "the body" }] }); + await client.fetchMessages("probe@e2e.example.test"); + + await expect(client.fetchMessageBody("probe@e2e.example.test", "m1")).resolves.toBe("the body"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws for a message id that was never listed", async () => { + const { client } = setup(); + + await expect(client.fetchMessageBody("probe@e2e.example.test", "missing")).rejects.toThrow(/no body for message missing/); + }); + }); + + function setup(input?: { + messages?: Array<{ id: string; receivedMs: number; subject: string; text: string }>; + response?: Response; + hangsUntilAborted?: boolean; + }) { + const fetchMock = input?.hangsUntilAborted + ? vi.fn((_url: string, init: RequestInit) => hangUntilAborted(init.signal)) + : vi.fn().mockResolvedValue(input?.response ?? Response.json(input?.messages ?? [])); + vi.stubGlobal("fetch", fetchMock); + + const client = new WorkerInboxClient({ + apiUrl: "https://inbox.test", + apiToken: "secret", + emailDomain: "e2e.example.test" + }); + + return { client, fetchMock }; + } +}); + +function hangUntilAborted(signal: AbortSignal | null | undefined): Promise { + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(new DOMException("The operation was aborted", "AbortError"))); + }); +} diff --git a/apps/deploy-web/vitest.config.ts b/apps/deploy-web/vitest.config.ts index cf17d6cde2..2df44263c5 100644 --- a/apps/deploy-web/vitest.config.ts +++ b/apps/deploy-web/vitest.config.ts @@ -58,6 +58,19 @@ export default defineConfig({ ...commonAlias } } + }, + { + extends: true, + test: { + name: "unit-e2e-helpers", + environment: "node", + include: ["tests/ui/**/*.unit.spec.ts"] + }, + resolve: { + alias: { + ...commonAlias + } + } } ] }