Files
redefined-designs/frontend/tests/e2e/auth.spec.ts
T
synAdminandClaude Opus 5 955049eac9
Linting / lint (pull_request) Successful in 3m0s
SonarQube Analysis / sonarqube (pull_request) Failing after 26m24s
fix(privacy): separate analytics consent from email consent (#56)
The previous commit widened the marketing consent sentence to cover the Brevo tracker, so one checkbox carried both purposes. That is the specific pattern GDPR rejects: consent has to be granular, and current EDPB guidance treats bundling tracking consent with subscription consent as invalid because the customer cannot accept one purpose and refuse the other. Quebec's Law 25 s.8.1 is stricter again — profiling technology has to be off until the person switches it on, with no pre-ticked box and no consent inherited from agreeing to something else. Building to both standards was the decision, since the storefront is publicly reachable and anyone can register.

So the marketing sentence is restored to exactly what it was, which leaves every existing email consent valid and untouched, and analytics gets its own column, its own sentence, its own checkbox at registration, its own toggle in the account page and its own endpoint. A customer can now hold either, both, or neither, and withdrawing one does not disturb the other.

The migration defaults analytics_consent to false, which is both the honest answer — none of the existing customers was ever asked — and what Law 25 requires. Nothing about this change opts anybody in.

Two details that are compliance requirements rather than wording preferences. The sentence names Brevo instead of saying "our email provider", because informed consent means the customer can tell who receives their data and a description they cannot act on is not disclosure. And the account toggle is as prominent and as easy to switch off as it is to switch on, because withdrawal has to be as easy as consenting.

The analytics endpoint is separate from the marketing one rather than a second field on it, so that a single call cannot change an answer the customer did not touch — the bundling problem moved from the form into the API. The unit tests now assert the two consents stay apart in both directions, including that the marketing sentence still says nothing about tracking, because re-bundling them would otherwise pass silently and is the mistake this project already made once.

Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 478 unit tests passing across 33 suites, frontend production build green. Not verified: the migration has not been run against a database, and integration and e2e need a Node this machine does not have active. None of this is legal advice and the wording is worth a lawyer's eye before it ships.

Refs #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:58:20 -05:00

219 lines
7.9 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();
});
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();
});
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();
});
});