Files
picpeak/tests/e2e/customer-portal-flow.spec.ts
T
Paul NothaftandPaul Nothaft 44adabca9e test(e2e): read the admin JWT from the cookie, not the login body (#1071)
Three specs acquire an admin token with `const body = await res.json();
return body.token`. The admin login has not returned a token in its body
for some time — establishAdminSession() sets the JWT as the httpOnly
`admin_token` cookie and responds with `res.json({ user })` — so the
token was undefined and every one of them failed at the first assertion,
before exercising anything they were written to cover.

Server-side the cookie and an Authorization: Bearer header are
interchangeable (see middleware/gallery.js, which reads the cookie first
and accepts an admin-typed Bearer second), so the fix is to read the
value back out of the context cookie jar and keep threading it as a
Bearer. Every downstream call in these specs stays exactly as it was.

Measured against a real stack, running only these three files:

  before   0 passed, 6 failed   — all six at the token assertion
  after    3 passed, 3 failed

The three that still fail no longer fail on auth: they get deep into the
flow and then miss UI that has since changed (a settings label, a
locator that no longer resolves). That is a separate and much larger
staleness problem across this directory — a full run is 12 passed
against roughly two dozen failures of that kind — and it is not
addressed here.

Worth knowing: no CI workflow runs tests/e2e at all, which is why this
rotted silently while `npm run test:e2e` stayed documented in CLAUDE.md.
Wiring it up is the obvious follow-up, but it has to wait until the
suite is actually green, or it would just pin main red.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-18 22:14:00 +02:00

260 lines
11 KiB
TypeScript

/**
* Customer portal end-to-end flow (#354).
*
* Covers the maintainer-flagged "core promise" of the feature:
* a customer can log in once and open every assigned gallery without
* re-entering the per-event password. Specifically:
*
* 1. Admin enables the Customer dashboard (Settings → Advanced features).
* 2. Admin creates an event AND invites a customer to that event.
* 3. Customer accepts the invitation (sets a password).
* 4. Customer logs in.
* 5. Customer's dashboard lists the assigned gallery.
* 6. Customer clicks the gallery → lands on /gallery/<slug> WITHOUT
* seeing a password prompt. The grid renders with photos.
*
* Side checks:
* - With the master toggle OFF, /customer/login redirects to /admin/login
* (frontend gate) and the customer-side API returns 410 Gone (backend
* gate). This is the kill-switch contract.
*
* Hits the API directly for setup (admin login, event create, photo upload,
* customer invite, accept-invite, gallery assignment) and only uses the
* browser for the parts that genuinely need the SPA: the gallery handoff
* itself, where the bug surface lives. Keeps the spec fast and avoids
* DOM-fragility on every admin form field.
*/
import { test, expect, Page } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'CustomerPortalGallery!1';
const CUSTOMER_PASSWORD = 'CustomerPortalUser!1';
async function adminLogin(page: Page): Promise<string> {
const res = await page.request.post('/api/auth/admin/login', {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
failOnStatusCode: false,
});
expect(res.ok()).toBeTruthy();
// The admin JWT is delivered as the httpOnly `admin_token` cookie, not in
// the response body. Server-side the cookie and an Authorization: Bearer
// header are interchangeable, so read it back out of the context jar and
// keep threading it as a Bearer — every downstream call stays as it was.
const cookies = await page.context().cookies();
const token = cookies.find((c) => c.name === 'admin_token')?.value;
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
return token as string;
}
async function setCustomerPortalEnabled(page: Page, adminToken: string, enabled: boolean) {
const res = await page.request.put('/api/admin/settings/advanced-features', {
headers: {
Authorization: `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
data: { customer_portal_enabled: enabled },
failOnStatusCode: false,
});
expect(res.ok()).toBeTruthy();
}
async function createEventWithPhoto(page: Page, adminToken: string) {
const eventName = `Customer Portal E2E ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const createRes = await page.request.post('/api/admin/events', {
headers: {
Authorization: `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
data: {
event_type: 'wedding',
event_name: eventName,
event_date: eventDate,
customer_name: 'Customer Portal Host',
customer_email: '[email protected]',
admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD,
expiration_days: 30,
allow_user_uploads: false,
allow_downloads: true,
},
failOnStatusCode: false,
});
if (!createRes.ok()) {
throw new Error(`Event create failed: ${createRes.status()} ${await createRes.text()}`);
}
const event = await createRes.json();
// One photo so the gallery grid has something to render after the
// customer hits it. Tests that the gallery loads, not that it's empty.
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
const buffer = fs.readFileSync(imagePath);
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
headers: { Authorization: `Bearer ${adminToken}` },
multipart: {
photos: { name: 'img1.png', mimeType: 'image/png', buffer },
category_id: 'individual',
},
failOnStatusCode: false,
});
expect(uploadRes.ok()).toBeTruthy();
return event;
}
/**
* Invite a customer, accept the invitation, return the email.
*
* The admin invite response echoes `invitation.token` only when
* NODE_ENV !== 'production' — that lets the spec skip the email
* round-trip without needing a separate /admin/email-queue endpoint
* or direct DB access. In production the token stays email-only.
*/
async function inviteAndAcceptCustomer(page: Page, adminToken: string) {
const email = `customer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.test`;
const inviteRes = await page.request.post('/api/admin/customers/invite', {
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
data: { email },
failOnStatusCode: false,
});
if (!inviteRes.ok()) {
throw new Error(`Customer invite failed: ${inviteRes.status()} ${await inviteRes.text()}`);
}
const inviteBody = await inviteRes.json();
// successResponse wraps in { success, data } — accept either shape.
const invitation = inviteBody.data?.invitation ?? inviteBody.invitation;
expect(invitation?.token, 'expected invitation.token echoed from non-prod /invite response').toBeTruthy();
const token = invitation.token;
// Accept the invitation as the customer (no auth).
const acceptRes = await page.request.post('/api/customer/auth/accept-invite', {
headers: { 'Content-Type': 'application/json' },
data: {
token,
name: 'Customer Portal E2E',
password: CUSTOMER_PASSWORD,
},
failOnStatusCode: false,
});
if (!acceptRes.ok()) {
throw new Error(`Accept failed: ${acceptRes.status()} ${await acceptRes.text()}`);
}
return { email };
}
async function getCustomerIdByEmail(page: Page, adminToken: string, email: string): Promise<number> {
const res = await page.request.get(`/api/admin/customers?search=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${adminToken}` },
failOnStatusCode: false,
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
const list = body.customers || body.data?.customers || body;
const match = (Array.isArray(list) ? list : []).find((c: any) => c.email === email);
expect(match, `expected customer with email ${email}`).toBeTruthy();
return match.id;
}
async function assignCustomerToEvent(page: Page, adminToken: string, eventId: number, customerId: number) {
// Update the event to include this customer's id in customer_account_ids
const res = await page.request.put(`/api/admin/events/${eventId}`, {
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
data: { customer_account_ids: [customerId] },
failOnStatusCode: false,
});
if (!res.ok()) {
throw new Error(`Customer assignment failed: ${res.status()} ${await res.text()}`);
}
}
// ---- the actual spec ---------------------------------------------------
test.describe('Customer portal — login + gallery handoff', () => {
test.beforeEach(async ({ page }) => {
// Make sure each test starts with a clean cookie jar so a leftover
// admin_token from a previous spec doesn't accidentally satisfy
// /api/customer/auth/session via the Authorization-Bearer fallback
// (which the customer-side specifically refuses, but the test
// shouldn't rely on that to pass).
await page.context().clearCookies();
});
test('customer can log in and open an assigned gallery without a password', async ({ page }) => {
// === setup (admin side) ===
const adminToken = await adminLogin(page);
await setCustomerPortalEnabled(page, adminToken, true);
const event = await createEventWithPhoto(page, adminToken);
const { email } = await inviteAndAcceptCustomer(page, adminToken);
const customerId = await getCustomerIdByEmail(page, adminToken, email);
await assignCustomerToEvent(page, adminToken, event.id, customerId);
try {
// === customer flow ===
// Login through the SPA (covers the cookie + setSession path that
// makes the dashboard render the assigned gallery on first paint).
await page.context().clearCookies();
await page.goto('/customer/login');
await page.getByLabel(/Email/i).fill(email);
await page.getByLabel(/Password/i).fill(CUSTOMER_PASSWORD);
await page.getByRole('button', { name: /Sign in/i }).click();
// Dashboard should list the assigned event by name.
await expect(page.getByRole('heading', { name: /Your galleries/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText(event.event_name, { exact: false })).toBeVisible({ timeout: 15000 });
// Click → gallery handoff. Watch for the URL change AND the absence
// of the per-event password prompt. Either of those failing is the
// primary regression this spec is designed to catch.
const navPromise = page.waitForURL(/\/gallery\//, { timeout: 15000 });
await page.getByRole('button', { name: /Open gallery/i }).first().click();
await navPromise;
// The password prompt would render `Enter Gallery Password` (heading
// or section label). It must NOT be present after the customer
// dashboard handoff.
await expect(page.getByText(/Enter Gallery Password/i)).toHaveCount(0);
// The grid tiles use the `.relative.group` selector across layouts;
// matches `auth-smoke.spec.ts`. At least one must render.
const tiles = page.locator('.relative.group');
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
} finally {
// Clean up: turn the feature off so the next test starts from a
// known state. Errors are swallowed — leftover state from a failed
// run is something to investigate manually.
await setCustomerPortalEnabled(page, adminToken, false).catch(() => { /* noop */ });
}
});
test('disabling the master toggle redirects /customer/login to /admin/login and refuses the API', async ({ page }) => {
// Verifies the kill-switch contract on both sides:
// - frontend: CustomerPortalGate redirects when public-settings says off
// - backend: /api/customer/auth/session returns 410 Gone with code
// CUSTOMER_PORTAL_DISABLED
const adminToken = await adminLogin(page);
await setCustomerPortalEnabled(page, adminToken, false);
// API gate: 410 Gone (the chosen status for "feature was here, admin
// turned it off" — distinct from a generic 403).
const apiRes = await page.request.get('/api/customer/auth/session', { failOnStatusCode: false });
expect(apiRes.status()).toBe(410);
const body = await apiRes.json().catch(() => ({}));
expect(body.code).toBe('CUSTOMER_PORTAL_DISABLED');
// Frontend gate: CustomerPortalGate redirects /customer/* away to
// /admin/login. Use waitForURL so we don't race the React Router
// <Navigate>.
await page.goto('/customer/login');
await page.waitForURL(/\/admin\/login/, { timeout: 10000 });
expect(page.url()).toContain('/admin/login');
});
});