Files
redefined-designs/frontend/tests/e2e/auth.spec.ts
T
synAdminandClaude Opus 5 2c6ac4d2be
Linting / lint (pull_request) Successful in 3m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 30m35s
feat(auth): offer Google sign-in on the login form (#345)
The last of the six, and the first a customer can see. The auth form is the single sign-in implementation rendered by both the route modal and the cart prompt, so the button goes in one place and appears in both.

Below the passkey button, which is below the password form. The order is deliberate and it is not about preference: a passkey is already on the device in front of the customer, while Google is a round trip to somebody else's site, and passwords are how every existing customer signs in. Each step down that list asks more of the person using it.

Absent rather than disabled where it is not configured, which is the same call #41 made for a browser without WebAuthn. It matters more here, because being unconfigured is the normal state rather than the exception: local development has no credentials, and QA cannot have any until #313. The storefront advertises a boolean through the existing public config, never the client id — the browser has no use for one, since the whole flow is a redirect the server builds.

Google's mark is inlined as SVG with their published colours and geometry. A hand-drawn approximation of somebody else's trademark is a compliance problem rather than a style choice, and a second origin on the sign-in path is a second thing that can be down.

The button is a navigation rather than a fetch, which makes it unlike every other control on that form. The flow leaves the application entirely, so there is no promise to await and no error to catch — the callback decides and redirects.

Where to return to is supplied by the caller, because only the caller knows. The route modal renders over a backdrop location and its own path is /login, so reading the current URL there would send the customer back to the form they just left; the router builds it from the backdrop instead. The cart prompt uses the page it interrupted. It cannot resume the interrupted action the way onSuccess does — the redirect leaves the app — so the customer lands back on the page and presses the button again.

That value is validated on the server and not in the browser. It has to be, since anyone can type the URL, and doing it in one place beats doing it twice in two languages.

The end-to-end test asserts the button is ABSENT, which is the behaviour local and QA actually have, and then signs in with the password form to show that its absence changes nothing. That is the point of putting the alternatives below rather than above.

docs/ops/google-sign-in.md records what has to be true outside the repository: the seven sections of the Google Auth Platform, the three scopes that keep publishing out of a verification review, the cutover checklist for #313, and the production smoke test. It states plainly that QA on the Synology hostname is impossible rather than merely unconfigured, because Google will not accept a redirect URI whose domain nobody can prove they own — the same wall #285 hit with Cloudflare.

The failure that document warns about hardest is leaving the consent screen in Testing. Only listed test users can then sign in, the refusal happens on Google's own page, and nothing reaches the storefront at all — so a customer reports a broken button and the logs are silent.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for.

Closes #345

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:13:18 -05:00

295 lines
11 KiB
TypeScript
Executable File

import { test, expect, PASSWORD, uniqueEmail } from './fixtures';
test.describe('Customer accounts', () => {
// Both names are required from anyone new so every email has a first name to
// greet with (#106). The server refuses without them; this is the form
// refusing first, so nobody gets a round trip to find out.
test('will not submit a registration without both names', async ({ authModal, header }) => {
await authModal.gotoRegister();
await authModal.fillRegistration({ email: uniqueEmail(), password: PASSWORD });
await authModal.submitRegistration();
await expect(authModal.registerDialog.getByText('First name is required')).toBeVisible();
await expect(authModal.registerDialog.getByText('Last name is required')).toBeVisible();
// Still on the form rather than signed in.
await expect(header.myAccountButton).toHaveCount(0);
});
test('marketing consent checkbox is unchecked by default', async ({ authModal }) => {
await authModal.gotoRegister();
await expect(authModal.marketingConsent).not.toBeChecked();
});
// A second, separate consent (#56). Unchecked for the same reason as the one
// above, and asserted separately because the two must be independently
// refusable — a single control covering both is the bundling GDPR treats as
// invalid, and Law 25 requires this one to start off.
test('analytics consent checkbox is unchecked by default', async ({ authModal }) => {
await authModal.gotoRegister();
await expect(authModal.analyticsConsent).not.toBeChecked();
});
test('the consent label is the exact wording the server records', async ({ authModal }) => {
await authModal.gotoRegister();
// The stored consent text is kept verbatim so the record says what the
// customer actually saw. Three different wordings were in circulation
// before the sign-in form was shared between the routes and the cart
// prompt, and none of them matched what was stored.
const consent =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
await expect(authModal.registerDialog).toContainText(consent);
// The analytics consent is a second, separate sentence and a second
// checkbox (#56). Asserted here for the same reason as the one above — it
// is stored verbatim, so the rendered label parting from the stored string
// defeats the record — and because the two being separate is the thing
// that makes the consent granular. Folding them back into one control
// would still pass the assertion above and would still be wrong.
const analytics =
'I agree that what I browse and buy on this site may be shared with Brevo, the service that sends our emails, so that what they contain is relevant to me. This is optional, separate from receiving the emails themselves, and I can turn it off at any time.';
await expect(authModal.registerDialog).toContainText(analytics);
});
// Drives the form rather than taking the `customer` fixture: this test is
// about registering, so the thing under test has to be the thing exercised.
test('registering signs the customer in and returns them where they were', async ({
page,
authModal,
header,
accountModal
}) => {
const email = uniqueEmail();
await authModal.gotoRegister();
await authModal.fillRegistration({
email,
password: PASSWORD,
firstName: 'Test',
lastName: 'Customer'
});
await authModal.submitRegistration();
await header.waitForSignedIn();
// Back on the storefront, signed in — not moved to the account page.
await expect(page).toHaveURL(/\/$/);
await accountModal.open();
await expect(accountModal.emailText(email)).toBeVisible();
});
// #41's requirement, and the half of it that can be proven without a real
// authenticator. Credentials bind to the Relying Party ID, so an actual
// passkey sign-in cannot be exercised here — but "a failed passkey prompt
// must return the customer to a usable password form, not a dead end" is
// about what happens *after* the attempt, and that is entirely testable.
test('a failed passkey attempt leaves the password form working', async ({
page,
customer,
accountModal,
authModal,
header
}) => {
// The fixture leaves the customer signed in, and this test is about the
// signed-out login form.
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await authModal.gotoLogIn();
const passkeyButton = authModal.logInDialog.getByRole('button', {
name: 'Sign in with a passkey'
});
// Chromium implements WebAuthn, so the control is offered here. A browser
// without it gets no button at all rather than a disabled one.
await expect(passkeyButton).toBeVisible();
// Failed at the first request, before the browser prompt — so this needs no
// authenticator and cannot hang waiting for a gesture nobody will make.
await page.route('**/api/customers/passkeys/login/begin', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' })
);
await passkeyButton.click();
// Says what to do next rather than only that something failed, and says
// nothing about whether an account exists.
await expect(page.getByText(/log in with your password instead/i)).toBeVisible();
// The actual requirement: not a dead end. The form behind the error still
// signs the customer in.
await authModal.logIn(customer.email, customer.password);
await header.waitForSignedIn();
});
// #345. Local development has no Google credentials, and neither does QA
// until #313 moves it off a hostname whose domain nobody can prove they own.
// So the button being ABSENT is the behaviour under test here, and it is the
// one that matters: a control that appears and then fails at Google is worse
// than one that was never offered.
test('offers no Google button when the environment is not configured for it', async ({
authModal,
accountModal,
customer,
header
}) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await authModal.gotoLogIn();
const google = authModal.logInDialog.getByRole('button', { name: /Sign in with Google/i });
await expect(google).toHaveCount(0);
// And the password form is untouched by its absence, which is the whole
// reason the alternatives sit below it rather than above.
await authModal.logIn(customer.email, customer.password);
await header.waitForSignedIn();
});
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await authModal.gotoLogIn();
await authModal.logIn(customer.email, 'wrong-password');
await expect(page.getByText('invalid email or password')).toBeVisible();
});
test('logging out returns to the home page and resets the header', async ({
page,
customer,
accountModal,
header
}) => {
await accountModal.openAndLogOut();
// A server round-trip followed by re-rendering the storefront behind the
// modal, so the 5s default is too tight when workers run concurrently.
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
await expect(header.logInButton).toBeVisible();
await expect(header.signUpButton).toBeVisible();
await expect(header.myAccountButton).toBeHidden();
});
test('the logged-out header survives a reload', async ({ page, customer, accountModal, header }) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
// Proves the server session was actually destroyed, rather than the header
// merely being repainted from stale client state.
await page.reload();
await expect(header.logInButton).toBeVisible();
await expect(header.myAccountButton).toBeHidden();
});
test('logging out does not leave the account page on the back stack', async ({
page,
customer,
accountModal
}) => {
await accountModal.openAndLogOut();
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
await page.goBack();
await expect(page).not.toHaveURL(/\/account/);
});
test('a failed logout says so instead of appearing to succeed', async ({
page,
customer,
accountModal
}) => {
await accountModal.open();
await page.route('**/api/customers/logout', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
);
await accountModal.logOut();
// The session cookie is still valid, so pretending to be logged out would
// silently log the customer back in on their next reload.
await expect(page.getByText(/couldn't log out/i)).toBeVisible();
await expect(page).toHaveURL(/\/account/);
});
});
test.describe('Auth routes are not dead ends', () => {
test('opening Log in from the header closes back to where browsing left off', async ({
page,
header,
authModal
}) => {
await page.goto('/?max_price=50000');
await header.logInButton.click();
await expect(authModal.logInDialog).toBeVisible();
await expect(page).toHaveURL(/\/login/);
await authModal.closeButton.click();
await expect(authModal.logInDialog).toBeHidden();
await expect(page).toHaveURL(/max_price=50000/);
});
test('a direct visit opens over the storefront rather than a blank page', async ({
authModal,
header
}) => {
await authModal.gotoLogIn();
await expect(authModal.logInDialog).toBeVisible();
await expect(header.siteTitle).toBeVisible();
});
test('switching between sign in and sign up keeps one history entry', async ({
page,
header,
authModal
}) => {
await page.goto('/?max_price=50000');
await header.logInButton.click();
await authModal.createAccountTab.click();
await expect(page).toHaveURL(/\/register/);
await authModal.logInTab.click();
await expect(page).toHaveURL(/\/login/);
// Back returns to browsing rather than walking through each tab visited.
await page.goBack();
await expect(page).toHaveURL(/max_price=50000/);
await expect(authModal.logInDialog).toBeHidden();
});
test('signing in from the header returns to the page behind, signed in', async ({
page,
customer,
accountModal,
header,
authModal
}) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await page.goto('/?max_price=50000');
await header.logInButton.click();
await authModal.logIn(customer.email, customer.password);
await header.waitForSignedIn();
await expect(page).toHaveURL(/max_price=50000/);
});
test('reaching password recovery from the login form keeps a way back', async ({
page,
authModal,
passwordReset
}) => {
await authModal.gotoLogIn();
await authModal.forgotPasswordButton.click();
await expect(passwordReset.requestDialog).toBeVisible();
await expect(page).toHaveURL(/\/forgot-password/);
await passwordReset.signInButton.click();
await expect(authModal.logInDialog).toBeVisible();
});
});