Skip to content
Open
19 changes: 11 additions & 8 deletions .github/actions/console-web-ui-testing/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/reusable-deploy-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
2 changes: 2 additions & 0 deletions apps/deploy-web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions apps/deploy-web/tests/ui/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,7 +86,7 @@ export async function registerNewUser(
}

async function registerPasswordless(page: Page): Promise<string> {
const otp = new MailsacCodeVerificationStrategy(testEnvConfig.MAILSAC_API_KEY);
const otp = createEmailVerificationStrategy();
const email = otp.generateEmail();
await signInPasswordless(page, email);
return email;
Expand Down Expand Up @@ -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<void> {
const otp = new MailsacCodeVerificationStrategy(testEnvConfig.MAILSAC_API_KEY);
const otp = createEmailVerificationStrategy();
const auth = new AuthPagePasswordless(page);

const sinceMs = Date.now();
Expand Down
32 changes: 32 additions & 0 deletions apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.ts
Original file line number Diff line number Diff line change
@@ -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}"`
});
}
});
35 changes: 35 additions & 0 deletions apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
10 changes: 8 additions & 2 deletions apps/deploy-web/tests/ui/fixture/test-env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
});
Expand All @@ -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
});
Expand Down
2 changes: 1 addition & 1 deletion apps/deploy-web/tests/ui/onboarding-journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions apps/deploy-web/tests/ui/passwordless-login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface InboxMessage {
id: string;
receivedMs: number;
subject?: string;
}

export interface InboxClient {
generateEmail(): string;
fetchMessages(email: string): Promise<InboxMessage[]>;
fetchMessageBody(email: string, messageId: string): Promise<string>;
}
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -73,7 +67,7 @@ export class MailsacCodeVerificationStrategy implements EmailVerificationStrateg
const scannedWithoutCode = new Set<string>();
const failures: CodeAttemptFailure[] = [];

for (;;) {
while (true) {
let candidate: CodeCandidate;
try {
candidate = await this.#pollForNextCode(input.email, input.sinceMs, triedMessageIds, scannedWithoutCode);
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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<MailsacMessage[]> {
return this.#fetch<MailsacMessage[]>(`${this.#baseUrl}/addresses/${email}/messages`);
}

async #fetchMessageBody(email: string, messageId: string): Promise<string> {
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<T>(path: string): Promise<T> {
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<void> {
Expand Down
Loading
Loading