Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions apps/web/e2e/auth-claim.flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { expect, test } from "@playwright/test";

function e2eBrowserToken() {
const token = process.env.VRDEX_E2E_BROWSER_TOKEN ?? (process.env.PLAYWRIGHT_BASE_URL ? undefined : "local-playwright-token");

if (!token) {
throw new Error("VRDEX_E2E_BROWSER_TOKEN must be set for hosted Playwright data-flow runs.");
}

return token;
}

function e2eRunId(testInfo: { project: { name: string }; workerIndex: number; repeatEachIndex: number }) {
const prefix = process.env.VRDEX_E2E_RUN_ID ?? "playwright-auth";

return `${prefix}-${testInfo.project.name}-${testInfo.workerIndex}-${testInfo.repeatEachIndex}-${Date.now()}`
.replace(/[^a-z0-9]+/gi, "-")
.toLowerCase()
.slice(0, 120);
}

test("verified email account with linked Discord can claim an E2E person profile @flow", async ({ page, request }, testInfo) => {
test.skip(
Boolean(process.env.PLAYWRIGHT_BASE_URL) && process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true",
"Hosted auth E2E helpers are not enabled for this target.",
);

const e2eToken = e2eBrowserToken();
const runId = e2eRunId(testInfo);
const runSuffix = runId.replace(/^playwright-auth-?/, "").slice(0, 48);
const displayName = `Playwright Claim ${runSuffix}`;
const email = `${runSuffix}@e2e.vrdex.local`;
const password = `VRDex-${runSuffix}-password-12345`;
let createdSlug: string | undefined;

try {
const profileResponse = await request.post("/api/e2e/profile-submissions", {
headers: { "x-vrdex-e2e-token": e2eToken },
data: {
runId,
profileType: "person",
displayName,
aliases: [`Claim ${runSuffix}`],
tags: ["playwright", "claim-flow"],
roleTags: ["Claim test profile"],
},
});
await expect(profileResponse).toBeOK();
const profile = (await profileResponse.json()) as { slug?: string };
createdSlug = profile.slug;
expect(createdSlug).toBeTruthy();

await page.goto("/sign-in");
await page.getByRole("button", { name: "Create account" }).click();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText(new RegExp(`Check ${email} for a verification code`, "i"))).toBeVisible();

const codeResponse = await request.post("/api/e2e/auth", {
headers: { "x-vrdex-e2e-token": e2eToken },
data: { action: "consume-code", email },
});
await expect(codeResponse).toBeOK();
const authCode = (await codeResponse.json()) as { code?: string };
expect(authCode.code).toBeTruthy();

await page.getByLabel("Verification code").fill(authCode.code!);
await Promise.all([
page.waitForURL(/\/account$/),
page.getByRole("button", { name: "Verify email" }).click(),
]);
await expect(page.getByRole("heading", { name: email })).toBeVisible();
await expect(page.getByText("Verified", { exact: true })).toBeVisible();

const linkResponse = await request.post("/api/e2e/auth", {
headers: { "x-vrdex-e2e-token": e2eToken },
data: { action: "link-discord", email, providerAccountId: `discord-${runSuffix}` },
});
await expect(linkResponse).toBeOK();

await page.goto("/account");
await expect(page.getByText("discord", { exact: true })).toBeVisible();
await page.getByLabel("Person slug").fill(createdSlug!);
await page.getByRole("button", { name: "Claim with Discord" }).click();
await expect(page.getByText(/Person profile claimed as claimed unverified/i)).toBeVisible();

await page.goto(`/p/${createdSlug}`);
await expect(page.getByRole("heading", { name: displayName })).toBeVisible();
await expect(page.getByText("Claimed", { exact: true })).toBeVisible();
} finally {
if (createdSlug || runId) {
await request.delete("/api/e2e/profile-submissions", {
headers: { "x-vrdex-e2e-token": e2eToken },
data: createdSlug ? { slug: createdSlug, runId } : { runId },
});
}

await request.delete("/api/e2e/auth", {
headers: { "x-vrdex-e2e-token": e2eToken },
data: { email },
});
}
});

test("E2E auth helper stays gated without the browser token @flow", async ({ request }) => {
test.skip(
Boolean(process.env.PLAYWRIGHT_BASE_URL) && process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true",
"Hosted auth E2E helpers are not enabled for this target.",
);

const e2eToken = e2eBrowserToken();
const payload = { action: "consume-code", email: "negative-gate@e2e.vrdex.local" };

const missingTokenResponse = await request.post("/api/e2e/auth", {
data: payload,
});
expect(missingTokenResponse.status()).toBe(403);

const wrongTokenResponse = await request.post("/api/e2e/auth", {
headers: { "x-vrdex-e2e-token": "wrong-token" },
data: payload,
});
expect(wrongTokenResponse.status()).toBe(403);

const malformedPostResponse = await request.post("/api/e2e/auth", {
headers: { "content-type": "application/json", "x-vrdex-e2e-token": e2eToken },
data: "{not-json",
});
expect(malformedPostResponse.status()).toBe(400);

const unsupportedActionResponse = await request.post("/api/e2e/auth", {
headers: { "x-vrdex-e2e-token": e2eToken },
data: { action: "unsupported", email: "negative-gate@e2e.vrdex.local" },
});
expect(unsupportedActionResponse.status()).toBe(400);

const missingDeleteTokenResponse = await request.delete("/api/e2e/auth", {
data: { email: "negative-gate@e2e.vrdex.local" },
});
expect(missingDeleteTokenResponse.status()).toBe(403);

const malformedDeleteResponse = await request.delete("/api/e2e/auth", {
headers: { "content-type": "application/json", "x-vrdex-e2e-token": e2eToken },
data: "{not-json",
});
expect(malformedDeleteResponse.status()).toBe(400);
});
16 changes: 16 additions & 0 deletions apps/web/playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { generateKeyPairSync } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -16,13 +17,26 @@ const skipWebServers = process.env.PLAYWRIGHT_SKIP_WEBSERVERS === "true" || Bool
const skipConvexServer = skipWebServers || process.env.PLAYWRIGHT_SKIP_CONVEX_DEV === "true";
const recordVideo = process.env.PLAYWRIGHT_RECORD_VIDEO === "true";
const e2eHelpersEnabled = process.env.VRDEX_ENABLE_E2E_HELPERS ?? (hostedBaseURL ? undefined : "true");
const localJwtKeys = hostedBaseURL
? {}
: (() => {
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwtPrivateKey = privateKey.export({ format: "pem", type: "pkcs8" }).toString().trimEnd().replace(/\n/g, " ");
const jwk = publicKey.export({ format: "jwk" });

return {
JWT_PRIVATE_KEY: process.env.JWT_PRIVATE_KEY ?? jwtPrivateKey,
JWKS: process.env.JWKS ?? JSON.stringify({ keys: [{ use: "sig", ...jwk }] }),
};
})();
const allowFixtureSearchFallthrough =
process.env.VRDEX_ALLOW_PLAYWRIGHT_FIXTURE_SEARCH_FALLTHROUGH === "true" ||
e2eHelpersEnabled === "true";
const localE2eHelperEnv = hostedBaseURL
? {}
: {
VRDEX_ENABLE_E2E_HELPERS: e2eHelpersEnabled ?? "true",
VRDEX_ENABLE_E2E_AUTH_HELPERS: process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS ?? "true",
VRDEX_E2E_BROWSER_TOKEN: process.env.VRDEX_E2E_BROWSER_TOKEN ?? "local-playwright-token",
VRDEX_E2E_CONVEX_SECRET: process.env.VRDEX_E2E_CONVEX_SECRET ?? "local-convex-e2e-secret",
};
Expand All @@ -36,6 +50,8 @@ const sharedEnv = {
...process.env,
CONVEX_URL: convexUrl,
NEXT_PUBLIC_CONVEX_URL: convexUrl,
SITE_URL: process.env.SITE_URL ?? baseURL,
...localJwtKeys,
VRDEX_ENABLE_PLAYWRIGHT_FIXTURES: "true",
...localE2eHelperEnv,
...(allowFixtureSearchFallthrough
Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/app/account/account-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h

async function submitDiscordPersonClaim(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const form = event.currentTarget;
const formData = new FormData(form);

setStatus({ kind: "submitting", label: "Claiming person profile..." });

Expand All @@ -85,15 +86,16 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h
href: result.profilePath,
}),
);
event.currentTarget.reset();
form.reset();
} catch (error) {
startTransition(() => setStatus({ kind: "error", message: claimErrorMessage(error) }));
}
}

async function submitCommunityDiscordClaim(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const form = event.currentTarget;
const formData = new FormData(form);

setStatus({ kind: "submitting", label: "Requesting community claim..." });

Expand All @@ -114,15 +116,16 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h
...("claimRequestId" in result ? { claimRequestId: result.claimRequestId } : {}),
}),
);
event.currentTarget.reset();
form.reset();
} catch (error) {
startTransition(() => setStatus({ kind: "error", message: claimErrorMessage(error) }));
}
}

async function submitVrchatProof(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const form = event.currentTarget;
const formData = new FormData(form);

setStatus({ kind: "submitting", label: "Creating proof code..." });

Expand All @@ -141,7 +144,7 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h
attemptId: result.attemptId,
}),
);
event.currentTarget.reset();
form.reset();
} catch (error) {
startTransition(() => setStatus({ kind: "error", message: claimErrorMessage(error) }));
}
Expand Down
93 changes: 93 additions & 0 deletions apps/web/src/app/api/e2e/auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ConvexHttpClient } from "convex/browser";
import { NextRequest, NextResponse } from "next/server";

import { api } from "@convex-generated-api";

export const dynamic = "force-dynamic";

function e2eError(message: string, status = 403) {
return NextResponse.json({ error: message }, { status });
}

function requireE2eAuthRequest(request: NextRequest) {
const browserToken = process.env.VRDEX_E2E_BROWSER_TOKEN?.trim();
const convexSecret = process.env.VRDEX_E2E_CONVEX_SECRET?.trim();
const requestToken = request.headers.get("x-vrdex-e2e-token") ?? request.cookies.get("vrdex_e2e_token")?.value;
const productionBlocked = process.env.VERCEL_ENV === "production" && process.env.VRDEX_ALLOW_PRODUCTION_E2E_HELPERS !== "true";

if (
productionBlocked ||
process.env.VRDEX_ENABLE_E2E_HELPERS !== "true" ||
process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true" ||
!browserToken ||
!convexSecret ||
requestToken !== browserToken
) {
return null;
}

return convexSecret;
}

function convexClient() {
const convexUrl = process.env.CONVEX_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL;

if (!convexUrl) {
throw new Error("Convex URL is not configured for E2E auth helpers.");
}

return new ConvexHttpClient(convexUrl);
}

export async function POST(request: NextRequest) {
const convexSecret = requireE2eAuthRequest(request);

if (convexSecret === null) {
return e2eError("E2E auth helpers are not enabled for this request.");
}

const rawBody = await request.json().catch(() => null);
if (!rawBody || typeof rawBody !== "object") {
return e2eError("Invalid JSON body.", 400);
}
const body = rawBody as Record<string, unknown>;
const action = typeof body.action === "string" ? body.action : "";
const email = typeof body.email === "string" ? body.email : "";

if (action === "consume-code") {
const result = await convexClient().mutation(api.e2e.consumeAuthCode, { secret: convexSecret, email });

return NextResponse.json(result);
}

if (action === "link-discord") {
const providerAccountId = typeof body.providerAccountId === "string" ? body.providerAccountId : "";
const result = await convexClient().mutation(api.e2e.linkDiscordAccountByEmail, {
secret: convexSecret,
email,
providerAccountId,
});

return NextResponse.json(result);
}

return e2eError("Unsupported E2E auth helper action.", 400);
}

export async function DELETE(request: NextRequest) {
const convexSecret = requireE2eAuthRequest(request);

if (convexSecret === null) {
return e2eError("E2E auth helpers are not enabled for this request.");
}

const rawBody = await request.json().catch(() => null);
if (!rawBody || typeof rawBody !== "object") {
return e2eError("Invalid JSON body.", 400);
}
const body = rawBody as Record<string, unknown>;
const email = typeof body.email === "string" ? body.email : "";
const result = await convexClient().mutation(api.e2e.cleanupAuthUserByEmail, { secret: convexSecret, email });

return NextResponse.json(result);
}
15 changes: 8 additions & 7 deletions apps/web/src/app/api/e2e/profile-submissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function requireE2eRequest(request: NextRequest) {
return null;
}

return true;
return convexSecret;
}

function convexClient() {
Expand All @@ -33,9 +33,9 @@ function convexClient() {
}

export async function POST(request: NextRequest) {
const allowed = requireE2eRequest(request);
const convexSecret = requireE2eRequest(request);

if (allowed === null) {
if (convexSecret === null) {
return e2eError("E2E helpers are not enabled for this request.");
}

Expand All @@ -46,6 +46,7 @@ export async function POST(request: NextRequest) {
const body = rawBody as Record<string, unknown>;

const result = await convexClient().mutation(api.e2e.submitProfile, {
secret: convexSecret,
runId: String(body.runId ?? "playwright"),
profileType: body.profileType === "community" ? "community" : "person",
displayName: String(body.displayName ?? ""),
Expand All @@ -65,9 +66,9 @@ export async function POST(request: NextRequest) {
}

export async function DELETE(request: NextRequest) {
const allowed = requireE2eRequest(request);
const convexSecret = requireE2eRequest(request);

if (allowed === null) {
if (convexSecret === null) {
return e2eError("E2E helpers are not enabled for this request.");
}

Expand All @@ -85,8 +86,8 @@ export async function DELETE(request: NextRequest) {
}

const result = slug
? await convexClient().mutation(api.e2e.cleanupProfileBySlug, { slug })
: await convexClient().mutation(api.e2e.cleanupProfilesByRunId, { runId });
? await convexClient().mutation(api.e2e.cleanupProfileBySlug, { secret: convexSecret, slug })
: await convexClient().mutation(api.e2e.cleanupProfilesByRunId, { secret: convexSecret, runId });

return NextResponse.json(result);
}
Loading
Loading