SonarQube reported 0% coverage for 78 unit, 134 integration and 83 end-to-end tests, so the coverage-on-new-code gate — the most useful thing SonarQube offers a project this size — has been failing permanently while looking configured. It now reports 69.6%, verified by a real scan. Backend coverage comes from both suites, written to separate directories because jest writes coverage/lcov.info by default and the second run would silently overwrite the first. Both are needed rather than just the fast one: the unit suite alone reports 11%, because everything in src/routes is exercised by the integration suite. That suite is manual-only after hanging for 3h12m post-run, so it runs here with --forceExit and the job carries a hard timeout; jest confirmed during testing that it would otherwise have hung. The frontend had no unit tests at all, so its coverage comes from Playwright driving an istanbul-instrumented dev server, collected per test by an auto-fixture and merged with nyc. The 17 specs now import from a local fixtures module that re-exports @playwright/test, which is what lets the fixture attach without touching each test body. Instrumentation is gated behind COVERAGE=true and loaded by dynamic import, since vite-plugin-istanbul is ESM-only while vite.config.ts evaluates as CommonJS. Both directions were checked rather than assumed: a normal build contains no instrumentation, and the dev server instruments nested modules as well as top-level ones — the first attempt used an include glob of src/* which would have silently missed everything under src/admin and src/cart. coverage:report fails when nothing was collected instead of writing an empty report, and that guard was fired deliberately to confirm it works. This project has been bitten twice by tools succeeding while measuring nothing — SonarQube skipping the whole frontend and still exiting EXECUTION SUCCESS in #67, and an ESLint matcher silently matching no files during #60 — and coverage has exactly that shape: an uninstrumented dev server lets every test pass while gathering nothing, and the 0% that follows reads as lost coverage rather than broken collection. Worth knowing when reading the numbers: end-to-end coverage flatters. Istanbul marks a line covered when the browser ran it, so a component rendered during a test counts as covered with nothing asserting anything about it. Recorded in the design doc and the project context rather than left to be discovered. Also declares sonar.tests so test files are analysed under the test rule set rather than as production code. Closes #61
162 lines
7.6 KiB
TypeScript
162 lines
7.6 KiB
TypeScript
import { test, expect, Page } from './fixtures';
|
|
import { Client } from 'pg';
|
|
|
|
const PASSWORD = 'supersecret123';
|
|
const NEW_PASSWORD = 'a-brand-new-password';
|
|
|
|
const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
|
|
|
async function register(page: Page, email: string) {
|
|
await page.goto('/register');
|
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
await page.getByLabel('Password').fill(PASSWORD);
|
|
await page.getByRole('button', { name: 'Create account' }).click();
|
|
// Registering now closes the auth modal and returns to the page behind it, so
|
|
// the header rather than the URL is what proves the session exists. The wait
|
|
// is generous because this is a bcrypt round-trip rather than a render.
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
// Log out lives inside the account view, which is a modal over the storefront.
|
|
async function logout(page: Page) {
|
|
await page.goto('/account');
|
|
await page.getByRole('dialog', { name: 'My Account' })
|
|
.getByRole('button', { name: 'Log out' }).click();
|
|
// 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 });
|
|
}
|
|
|
|
test.describe('Password reset', () => {
|
|
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
|
|
await page.goto('/login');
|
|
// Recovery is now reached from the login modal rather than a link on a
|
|
// page, and its title is the modal's rather than a heading.
|
|
await page.getByRole('button', { name: 'Forgot password?' }).click();
|
|
|
|
await expect(page).toHaveURL(/\/forgot-password/);
|
|
await expect(page.getByRole('dialog', { name: 'Reset your password' })).toBeVisible();
|
|
});
|
|
|
|
test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => {
|
|
await page.goto('/forgot-password');
|
|
await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com');
|
|
await page.getByRole('button', { name: 'Send reset link' }).click();
|
|
|
|
// Identical wording either way; a differing message would make this an
|
|
// account-enumeration oracle.
|
|
await expect(page.getByText('Check your email')).toBeVisible();
|
|
await expect(page.getByText(/If an account exists/)).toBeVisible();
|
|
});
|
|
|
|
test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => {
|
|
await page.goto('/reset-password');
|
|
|
|
await expect(page.getByText('This link is incomplete')).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0);
|
|
});
|
|
|
|
test('rejects a mismatched confirmation before contacting the server', async ({ page }) => {
|
|
await page.goto('/reset-password?token=whatever');
|
|
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
await page.getByLabel('Confirm new password').fill('something-else-entirely');
|
|
await page.getByRole('button', { name: 'Set new password' }).click();
|
|
|
|
await expect(page.getByText('The passwords do not match')).toBeVisible();
|
|
});
|
|
|
|
test('reports an invalid token rather than appearing to succeed', async ({ page }) => {
|
|
await page.goto('/reset-password?token=not-a-real-token');
|
|
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
|
await page.getByRole('button', { name: 'Set new password' }).click();
|
|
|
|
await expect(page.getByText('invalid or expired token')).toBeVisible();
|
|
await expect(page).toHaveURL(/\/reset-password/);
|
|
});
|
|
|
|
test('a customer can reset their password and sign in with the new one', async ({ page, request }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
await logout(page);
|
|
|
|
// The reset link arrives by email, which the tests can't read. Request the
|
|
// reset through the real endpoint, then read the issued token the way the
|
|
// customer's mail client would deliver it.
|
|
const requested = await request.post('/api/customers/request-password-reset', { data: { email } });
|
|
expect(requested.ok()).toBeTruthy();
|
|
|
|
const token = await readResetToken(email);
|
|
await page.goto(`/reset-password?token=${token}`);
|
|
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
|
await page.getByRole('button', { name: 'Set new password' }).click();
|
|
|
|
// The reset signs them in and closes back to the storefront — the link came
|
|
// from an email, so there is no page behind it to return to.
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
await page.goto('/account');
|
|
await expect(page.getByText(email)).toBeVisible();
|
|
|
|
// And the new password actually works on a fresh sign-in.
|
|
await logout(page);
|
|
await page.goto('/login');
|
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
await page.getByLabel('Password').fill(NEW_PASSWORD);
|
|
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
// button of its own, which is what opened this one.
|
|
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
});
|
|
|
|
test('the old password stops working after a reset', async ({ page, request }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
await logout(page);
|
|
|
|
await request.post('/api/customers/request-password-reset', { data: { email } });
|
|
const token = await readResetToken(email);
|
|
await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
|
|
|
|
await page.goto('/login');
|
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
await page.getByLabel('Password').fill(PASSWORD);
|
|
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
// button of its own, which is what opened this one.
|
|
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
|
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// The token is only ever delivered by email, which these tests cannot read.
|
|
//
|
|
// It is read straight from the database rather than through a helper endpoint:
|
|
// an endpoint that returns a password-reset token for an arbitrary address is
|
|
// account takeover for every customer if it is ever reachable, and an
|
|
// environment gate is a thin thing to stand between that and production. Doing
|
|
// it here keeps the capability entirely inside the test process.
|
|
async function readResetToken(email: string): Promise<string> {
|
|
const client = new Client({
|
|
host: process.env.TEST_PGHOST || 'localhost',
|
|
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
|
user: process.env.TEST_PGUSER || 'redefined_test',
|
|
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
|
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
|
});
|
|
await client.connect();
|
|
try {
|
|
const { rows } = await client.query(
|
|
`SELECT t.token FROM customer_tokens t
|
|
JOIN customers c ON c.id = t.customer_id
|
|
WHERE c.email = $1 AND t.kind = 'password_reset'
|
|
ORDER BY t.created_at DESC LIMIT 1`,
|
|
[email]
|
|
);
|
|
if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
|
|
return rows[0].token as string;
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|