diff --git a/.gitignore b/.gitignore index 2f09a91..8faba04 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr coverage +test-results +playwright-report *.local !.env.local.example diff --git a/CLAUDE.md b/CLAUDE.md index 837a2cd..a933d56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,7 @@ See `docs/implementation-plan/README.md` for the full 36-step plan. - Step 33 (Mobile Responsive Refinements) — complete - Step 34 (Loading States, Error Boundaries, Toast Notifications) — complete - Step 35 (Security Hardening) — complete +- Step 36 (E2E Tests - Playwright) — complete ### Implementation Notes diff --git a/README.md b/README.md index 7a69c95..1ff0c01 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,29 @@ pnpm supabase:status # Show URLs and keys pnpm dev:reset # Reset database then start Vite ``` +### E2E Tests (Playwright) + +End-to-end tests run real crypto (real Argon2id in the worker, no mocks) against a production `vite preview` build served on `:4173`, talking to the local Supabase instance. + +Prerequisites: + +- **Docker** running and `pnpm supabase:start` so local Supabase is up. +- `.env.local` populated with `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` (the **Publishable** key from `pnpm supabase:status`). Raw DB assertions in the security spec connect as the `postgres` superuser via `E2E_DB_URL` (default `postgresql://postgres:postgres@127.0.0.1:54322/postgres`), so no service-role key is required. +- Chromium installed for Playwright (one-time): + +```bash +pnpm dlx playwright install chromium +``` + +Run the suite: + +```bash +pnpm test:e2e # headless +pnpm test:e2e:ui # interactive Playwright UI +``` + +The `webServer` config builds the app and serves `vite preview` automatically. Tests run **serially** (`workers: 1`) and each auth op runs a real Argon2id derivation, so a full run takes a few minutes. A global setup resets the database once before the suite, and each spec truncates `auth.users` + `private.rate_limits` between tests to avoid tripping the shared-IP pre-auth RPC rate limits. + ## Architecture ``` diff --git a/docs/implementation-plan/09-phase-9-polish.md b/docs/implementation-plan/09-phase-9-polish.md index 8a5b556..9344192 100644 --- a/docs/implementation-plan/09-phase-9-polish.md +++ b/docs/implementation-plan/09-phase-9-polish.md @@ -1,4 +1,4 @@ -# Phase 9: Polish +# Phase 9: Polish ✅ ## Step 33 — Mobile Responsive Refinements ✅ @@ -88,7 +88,7 @@ --- -## Step 36 — E2E Tests (Playwright) +## Step 36 — E2E Tests (Playwright) ✅ **Goal:** Full end-to-end tests for critical user flows. @@ -111,6 +111,8 @@ - Register user A → verify user A cannot read user B's data (via API) - Verify encrypted fields in DB are not plaintext - Verify auth_hash in DB is not the real password +- `e2e/realtime.spec.ts`: + - Cross-session realtime sync: two browser contexts, A mutates while B asserts the change propagates without reload - `playwright.config.ts` — configure to run against local Supabase **Tests:** diff --git a/docs/implementation-plan/README.md b/docs/implementation-plan/README.md index deff71a..1aeffb8 100644 --- a/docs/implementation-plan/README.md +++ b/docs/implementation-plan/README.md @@ -73,7 +73,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta - [x] Step 33 — Mobile Responsive Refinements - [x] Step 34 — Loading States, Error Boundaries, Toast Notifications - [x] Step 35 — Security Hardening -- [ ] Step 36 — E2E Tests (Playwright) +- [x] Step 36 — E2E Tests (Playwright) --- diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..93e7ef7 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from '@playwright/test' + +import { resetUserData } from './helpers/db' +import { clickSidebarButton, clickSidebarButtonByName } from './helpers/navigation' +import { login, registerUser, uniqueUsername } from './helpers/users' + +/** + * Auth flow E2E — real crypto against the production preview build. + * + * Each test registers a fresh user (seed users carry placeholder key material + * that cannot be unwrapped) and runs real Argon2id in the worker. `beforeEach` + * truncates `auth.users` + `private.rate_limits` so no pre-auth RPC (login + * salts, username check) trips the shared-IP limiter. Per-test browser contexts + * isolate Supabase sessions. + */ + +const PASSWORD = 'TestPass123!' + +test.describe('auth', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + test('register shows a 12-word mnemonic and lands on the dashboard welcome', async ({ page }) => { + const username = uniqueUsername('reg') + const { mnemonic } = await registerUser(page, username, PASSWORD) + + // registerUser already asserted the cell count; re-assert on the string. + expect(mnemonic.split(' ').filter(Boolean)).toHaveLength(12) + + // Registration auto-unlocks the vault and auto-creates a first entry, so + // /dashboard shows DashboardWelcome — not EmptyState. + await expect(page).toHaveURL(/\/dashboard$/) + await expect(page.getByText(`Welcome ${username}`)).toBeVisible() + }) + + test('login with the correct password reaches the unlocked dashboard', async ({ page }, testInfo) => { + const username = uniqueUsername('login') + await registerUser(page, username, PASSWORD) + + // Registration leaves the session active — log out, then back in to + // exercise the real /login flow. + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + + await login(page, username, PASSWORD) + + await expect(page).toHaveURL(/\/dashboard$/) + // The auto-created first entry persists, so /dashboard shows DashboardWelcome. + await expect(page.getByText(`Welcome ${username}`)).toBeVisible() + }) + + test('unlocking the vault with the correct password restores access', async ({ page }, testInfo) => { + const username = uniqueUsername('unlock') + await registerUser(page, username, PASSWORD) + + // Log out and back in so the unlock goes through the authenticated + // VaultUnlockDialog (reached via the header VaultIndicator after a manual + // lock, not the /login form). + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + await login(page, username, PASSWORD) + + // Login auto-unlocks, so lock manually to reach the unlock dialog. + await clickSidebarButtonByName(page, testInfo, 'Lock vault') + // The indicator flips to "Vault locked" — assert it before unlocking. + await expect(page.locator('header').getByText('Vault locked', { exact: true })).toBeVisible() + + // Scope to
— the sidebar VaultLockButton exposes an identically + // named button that would trip Playwright strict mode. + await page.locator('header').getByRole('button', { name: 'Unlock vault', exact: true }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + await dialog.locator('#password-confirm').fill(PASSWORD) + await page.getByTestId('vault-unlock-submit').click() + + // Correct password → unwrap succeeds → dialog closes (a wrong password + // throws before close()). The indicator flips back to "unlocked". + await expect(dialog).not.toBeVisible() + await expect(page.locator('header').getByText('Vault unlocked', { exact: true })).toBeVisible() + }) + + test('unlocking the vault with a wrong password shows the vault error', async ({ page }, testInfo) => { + const username = uniqueUsername('wrong') + await registerUser(page, username, PASSWORD) + + // Log out and back in so the wrong-password attempt goes through the + // authenticated vault-unlock path (login itself rejects at the Supabase + // Auth step with a different login-page toast). + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + await login(page, username, PASSWORD) + + // Login auto-unlocks, so lock manually to reach the unlock dialog. + await clickSidebarButtonByName(page, testInfo, 'Lock vault') + + // Scope to
— the sidebar VaultLockButton exposes an identically + // named button that would trip Playwright strict mode. + await page.locator('header').getByRole('button', { name: 'Unlock vault', exact: true }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + await dialog.locator('#password-confirm').fill('DefinitelyNotThePassword!') + await page.getByTestId('vault-unlock-submit').click() + + // Wrong password → unwrap fails → DecryptionError → mapped to + // vault:errors.wrongPassword; dialog stays open for retry. + await expect(dialog.getByText('Wrong password', { exact: true })).toBeVisible() + await expect(dialog).toBeVisible() + }) + + test('logout from the sidebar redirects to login', async ({ page }, testInfo) => { + const username = uniqueUsername('logout') + await registerUser(page, username, PASSWORD) + + await clickSidebarButton(page, testInfo, 'logout-button') + + // logoutUser clears local auth state; the _authenticated guard redirects + // to /login. + await expect(page).toHaveURL(/\/login$/) + }) + + test('register with an already-taken username shows the availability error and disables submit', async ({ page }, testInfo) => { + const username = uniqueUsername('taken') + await registerUser(page, username, PASSWORD) + + // Log out so /register renders (the _public guard redirects authed users + // to /dashboard). + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + await page.goto('/register') + + // After the 1500ms debounce, check_username_availability returns false → + // status flips to 'taken' → the message renders and submit disables. + await page.locator('#username').fill(username) + await expect(page.getByText('Username is already taken', { exact: true })).toBeVisible() + await expect(page.getByTestId('register-submit')).toBeDisabled() + await expect(page).toHaveURL(/\/register$/) + }) +}) diff --git a/e2e/crypto.spec.ts b/e2e/crypto.spec.ts new file mode 100644 index 0000000..fbfb7f4 --- /dev/null +++ b/e2e/crypto.spec.ts @@ -0,0 +1,260 @@ +import { expect, test, type Page } from '@playwright/test' + +import { resetUserData } from './helpers/db' +import { + clickEntryNav, + clickFirstEntry, + clickSidebarButton, + createEntry, + navigateToSettings, +} from './helpers/navigation' +import { login, registerUser, uniqueUsername } from './helpers/users' + +/** + * Crypto-flow E2E — real Argon2id against the production preview build. + * + * Covers the four flows docs/e2ee-plan.md groups under `crypto.spec.ts`: + * change password, verify + regenerate seed phrase, account recovery, and + * single-field key rotation. Each test registers a fresh user (seed users + * carry placeholder key material that cannot be unwrapped) and drives the real + * UI, so each auth op runs a real Argon2id derivation (~1s) and the full key + * hierarchy is exercised end-to-end. `beforeEach` truncates `auth.users` + + * `private.rate_limits` so no pre-auth RPC trips the shared-IP limiter. + */ + +const PASSWORD = 'TestPass123!' +const NEW_PASSWORD = 'NewPass456!' + +const NOTE_VALUE = 'Rotatable note body — must survive key rotation.' + +/** + * Reads the 12-word mnemonic from the `[data-testid="mnemonic-words"]` grid, + * stripping the "N." index prefix each cell renders. Used to compare the + * regenerated mnemonic against the one captured at registration. + */ +async function readMnemonicFromDialog(page: Page): Promise { + const grid = page.getByTestId('mnemonic-words') + await expect(grid).toBeVisible() + const cells = grid.locator('.font-mono') + await expect(cells).toHaveCount(12) + const texts = await cells.allInnerTexts() + return texts.map((text) => text.replace(/^\s*\d+\.\s*/, '').trim()).join(' ') +} + +/** + * Fills the 12 MnemonicInput word cells one at a time. Per-word `fill` (not + * paste) lets each cell's blur validation run against the BIP-39 wordlist; + * real words pass, so `isValid` flips true and submit enables. + */ +async function fillMnemonicInputs(page: Page, mnemonic: string): Promise { + const words = mnemonic.split(' ').filter(Boolean) + for (let i = 0; i < words.length; i++) { + await page.getByTestId(`mnemonic-word-${i + 1}`).fill(words[i]) + } +} + +test.describe('crypto', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + test('change password: old password fails, new password unlocks the vault', async ({ page }, testInfo) => { + const username = uniqueUsername('changepw') + await registerUser(page, username, PASSWORD) + + // Settings → Change password. The dialog re-derives the passwordKey + // (Argon2id), re-wraps the master key, uploads the new envelope, and updates + // Supabase Auth — all before the success toast. Navigate in-app so the + // unlocked vault survives. + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + await page.getByTestId('settings-change-password').click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.locator('#current-password').fill(PASSWORD) + await dialog.locator('#new-password').fill(NEW_PASSWORD) + await dialog.locator('#confirm-password').fill(NEW_PASSWORD) + await page.getByTestId('change-password-submit').click() + + await expect(page.getByText('Password changed successfully', { exact: true })).toBeVisible() + await expect(dialog).not.toBeVisible() + + // Logout clears the local session + key state. + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + + // The old authHash no longer matches Supabase Auth, so login rejects and + // stays on /login with the invalid-credentials toast. + await page.locator('#username').fill(username) + await page.locator('#password').fill(PASSWORD) + await page.getByTestId('login-submit').click() + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByText('Invalid username or password', { exact: true })).toBeVisible() + + // The new password derives the matching authHash and unlocks the vault. + await login(page, username, NEW_PASSWORD) + await expect(page).toHaveURL(/\/dashboard$/) + // The auto-created first entry survives, so DashboardWelcome shows. + await expect(page.getByText(`Welcome ${username}`)).toBeVisible() + }) + + test('change password: wrong current password shows an error and leaves the session intact', async ({ page }, testInfo) => { + const username = uniqueUsername('changepwbad') + await registerUser(page, username, PASSWORD) + + // Settings → Change password dialog (in-app nav keeps the vault unlocked). + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + await page.getByTestId('settings-change-password').click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + // Valid new/confirm pair, wrong current password. The schema passes so + // changeUserPassword runs; rewrapMasterKey's unwrap throws DecryptionError + // in Step 1 — before the DB upload or Auth update. No server state mutates. + await dialog.locator('#current-password').fill('WrongCurrentPass!') + await dialog.locator('#new-password').fill(NEW_PASSWORD) + await dialog.locator('#confirm-password').fill(NEW_PASSWORD) + await page.getByTestId('change-password-submit').click() + + // DecryptionError → wrongCurrentPassword toast. Error path skips + // reset()/close(), so the dialog stays open. + await expect(page.getByText('Current password is incorrect', { exact: true })).toBeVisible() + await expect(dialog).toBeVisible() + await expect(page).toHaveURL(/\/settings$/) + + // Session intact: dismiss the dialog and open the first entry. The field + // editor renders only while unlocked (locked shows LockedVaultCard), so + // field-input-note visible proves the keys survived. + await page.keyboard.press('Escape') + await expect(dialog).not.toBeVisible() + await clickFirstEntry(page, testInfo) + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + await expect(page.getByTestId('field-input-note')).toBeVisible() + }) + + test('verify seed phrase confirms the stored mnemonic, regenerate produces a new one', async ({ page }, testInfo) => { + const username = uniqueUsername('mnemonic') + const { mnemonic } = await registerUser(page, username, PASSWORD) + + // Verify Seed Phrase: paste the registration mnemonic into the 12 cells + // and submit. verifyMnemonic unwraps the stored recovery data with it — a + // match shows a success toast. Navigate in-app so the vault stays unlocked. + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + await page.getByTestId('settings-verify-mnemonic').click() + const verifyDialog = page.getByRole('dialog') + await expect(verifyDialog).toBeVisible() + await fillMnemonicInputs(page, mnemonic) + await page.getByTestId('verify-mnemonic-submit').click() + await expect(page.getByText('Your recovery phrase is valid', { exact: true })).toBeVisible() + await expect(verifyDialog).not.toBeVisible() + + // Regenerate: confirm with the current password (Argon2id + master-key + // unwrap), then a fresh mnemonic is generated, saved as the new recovery + // data, and shown in MnemonicDialog. It must differ from the registration + // mnemonic (collision probability is negligible). + await page.getByTestId('settings-regenerate-mnemonic').click() + const pwDialog = page.getByRole('dialog') + await expect(pwDialog).toBeVisible() + await pwDialog.locator('#password-confirm').fill(PASSWORD) + await page.getByTestId('regenerate-mnemonic-submit').click() + + // PasswordConfirmDialog hands off to MnemonicDialog (same role); read the + // new mnemonic from the mnemonic-words grid. + const newMnemonic = await readMnemonicFromDialog(page) + expect(newMnemonic.split(' ').filter(Boolean)).toHaveLength(12) + expect(newMnemonic).not.toEqual(mnemonic) + + // Acknowledge + Continue closes the dialog with a success toast. + await page.getByTestId('mnemonic-acknowledge').check() + await page.getByTestId('mnemonic-continue').click() + await expect(page.getByText('Seed phrase regenerated successfully', { exact: true })).toBeVisible() + }) + + test('account recovery: mnemonic + new password restores access', async ({ page }, testInfo) => { + const username = uniqueUsername('recover') + const { mnemonic } = await registerUser(page, username, PASSWORD) + + // /recover is public; log out first so the _authenticated guard doesn't + // redirect back to /dashboard. Reach it via the login "Forgot password?" + // link (in-app navigation). + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + + await page.getByRole('link', { name: 'Forgot password?' }).click() + await page.waitForURL('**/recover') + + // Step 1 — username + mnemonic. validateMnemonic fetches the pre-auth + // recovery data and unwraps the master key with the mnemonic. + await page.locator('#username').fill(username) + await fillMnemonicInputs(page, mnemonic) + await page.getByTestId('recover-submit').click() + + // Step 1 → Step 2 is in-place (URL stays /recover); wait for the + // new-password form before filling. + await expect(page.locator('#new-password')).toBeVisible() + await page.locator('#new-password').fill(NEW_PASSWORD) + await page.locator('#confirm-new-password').fill(NEW_PASSWORD) + await page.getByTestId('recover-set-password').click() + + // recover_account atomically rewrites auth + salts + the master-key + // envelope, then auto-logs in. Happy path lands on /dashboard; + // RecoveryLoginError falls back to /login with a success toast. Either way + // the new password works — wait for either URL, then drive an explicit + // login to prove it unlocks the vault. + await expect(page).toHaveURL(/\/(dashboard|login)$/) + if (page.url().endsWith('/dashboard')) { + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + } + + await login(page, username, NEW_PASSWORD) + await expect(page).toHaveURL(/\/dashboard$/) + // Recovery preserves the auto-created first entry, so DashboardWelcome + // shows. + await expect(page.getByText(`Welcome ${username}`)).toBeVisible() + }) + + test('rotating a single field key increments the version and preserved content still decrypts', async ({ page }, testInfo) => { + const username = uniqueUsername('rotate') + await registerUser(page, username, PASSWORD) + + // Create an entry and type a note so there is real ciphertext to re-encrypt. + const entryId = await createEntry(page, testInfo) + await page.getByTestId('field-input-note').fill(NOTE_VALUE) + const noteCard = page.getByTestId('field-card-note') + await expect(noteCard.getByTestId('save-indicator')).toHaveText('Saved') + + // Settings → Key versions. The note row reports its current wrapped-key + // version via a `font-mono` "vN" span. Navigate in-app so the vault stays + // unlocked. Expand the key management collapsible first — it starts collapsed. + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + await page.getByTestId('settings-key-management-trigger').click() + const noteRow = page.getByTestId('settings-rotate-key-note').locator('xpath=ancestor::div[1]') + const noteVersion = noteRow.locator('.font-mono') + await expect(noteVersion).toHaveText('v1') + + // Rotate just the note key. rotateFieldKey re-encrypts every entry's note + // ciphertext, atomically swaps the wrapped key server-side, then stores the + // v2 key in the vault + updates the cached envelope the version label reads. + await page.getByTestId('settings-rotate-key-note').click() + // RotateFieldKeyDialog is a Base UI AlertDialog, so role is `alertdialog`, + // not `dialog` like the other settings dialogs. + const dialog = page.getByRole('alertdialog') + await expect(dialog).toBeVisible() + await page.getByTestId('rotate-field-key-confirm').click() + + await expect(page.getByText('Note key rotated to v2', { exact: true })).toBeVisible() + await expect(dialog).not.toBeVisible() + await expect(noteVersion).toHaveText('v2') + + // Navigate back to the entry via the sidebar (in-app, no reload). The + // field query was invalidated, so the note refetches and re-decrypts with + // the v2 key now in the vault — the plaintext must survive intact. + await clickEntryNav(page, entryId, testInfo) + await expect(page).toHaveURL(new RegExp(`/dashboard/${entryId}$`)) + await expect(page.getByTestId('field-input-note')).toHaveValue(NOTE_VALUE) + }) +}) diff --git a/e2e/fields.spec.ts b/e2e/fields.spec.ts new file mode 100644 index 0000000..2588292 --- /dev/null +++ b/e2e/fields.spec.ts @@ -0,0 +1,214 @@ +import { expect, test, type Page } from '@playwright/test' + +import { resetUserData } from './helpers/db' +import { + clickEntryNav, + clickFirstEntry, + clickSidebarButton, + createEntry, + entryIdFromUrl, + entryNavLocator, + getSidebar, +} from './helpers/navigation' +import { login, registerUser, uniqueUsername } from './helpers/users' + +/** + * Entry / field CRUD E2E — real crypto against the production preview build. + * + * Each test registers a fresh user (seed users carry placeholder key material + * that cannot be unwrapped), creates an entry through the real UI, types into + * all four encrypted fields, and asserts the auto-save indicator reaches + * "Saved" per field. Persistence is verified across a same-session reload + * (vault locks on reload, so it must be re-unlocked) and a full logout → login. + * Real Argon2id runs in the worker on every auth op. `beforeEach` truncates + * `auth.users` + `private.rate_limits` so no pre-auth RPC trips the limiter. + */ + +const PASSWORD = 'TestPass123!' + +const FIELD_VALUES = { + title: 'My secret note title', + website: 'https://example.com', + email: 'vault@example.com', + note: 'Multi-line\nencrypted note body.', +} as const + +type FieldValues = Record<(typeof FIELD_NAMES)[number], string> + +const FIELD_NAMES = ['title', 'website', 'email', 'note'] as const + +test.describe('fields', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + /** + * Fills all four field inputs and asserts each SaveIndicator reaches "Saved". + * Auto-save debounces 1s after the last keystroke, then transitions + * DIRTY → SAVING → SAVED; `toHaveText('Saved')` auto-retries through SAVING + * until the save round-trip completes. + */ + async function fillAllFieldsAndAwaitSaved(page: Page, values: FieldValues = FIELD_VALUES): Promise { + for (const fieldName of FIELD_NAMES) { + await page.getByTestId(`field-input-${fieldName}`).fill(values[fieldName]) + } + for (const fieldName of FIELD_NAMES) { + const card = page.getByTestId(`field-card-${fieldName}`) + await expect(card.getByTestId('save-indicator')).toHaveText('Saved') + } + } + + /** + * Unlocks the vault from the locked dashboard state (after a reload drops + * the in-memory master key). Clicks the LockedVaultCard "Unlock vault" + * button, submits the password, and waits for the dialog to close. + */ + async function unlockVault(page: Page, password: string): Promise { + // Scope to
: the header and sidebar also expose an "Unlock vault" + // button that would trip Playwright strict mode. LockedVaultCard renders + // the one in main content. + await page.getByRole('main').getByRole('button', { name: 'Unlock vault', exact: true }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.locator('#password-confirm').fill(password) + await page.getByTestId('vault-unlock-submit').click() + await expect(dialog).not.toBeVisible() + } + + /** Asserts every field input re-decrypted to the originally typed value. */ + async function expectFieldsRestored(page: Page, values: FieldValues = FIELD_VALUES): Promise { + for (const fieldName of FIELD_NAMES) { + await expect(page.getByTestId(`field-input-${fieldName}`)).toHaveValue(values[fieldName]) + } + } + + test('typing into all four fields auto-saves and persists across reload + re-unlock', async ({ page }, testInfo) => { + const username = uniqueUsername('fields') + await registerUser(page, username, PASSWORD) + + const entryId = await createEntry(page, testInfo) + await fillAllFieldsAndAwaitSaved(page) + + // Reload drops the in-memory master key → vault locks → LockedVaultCard. + // Re-unlock and confirm the server ciphertext re-decrypts to the values + // typed before the reload. + await page.reload() + await unlockVault(page, PASSWORD) + await expect(page).toHaveURL(new RegExp(`/dashboard/${entryId}$`)) + await expectFieldsRestored(page) + }) + + test('saved values re-decrypt after logout and a fresh login', async ({ page }, testInfo) => { + const username = uniqueUsername('relogin') + await registerUser(page, username, PASSWORD) + + const entryId = await createEntry(page, testInfo) + await fillAllFieldsAndAwaitSaved(page) + + // Logout clears local auth + key state; login re-derives the passwordKey + // and auto-unlocks, so navigating back to the entry decrypts the persisted + // ciphertext without a separate unlock. Reach it via its sidebar nav item + // (in-app) so the vault stays unlocked. + await clickSidebarButton(page, testInfo, 'logout-button') + await expect(page).toHaveURL(/\/login$/) + + await login(page, username, PASSWORD) + await clickEntryNav(page, entryId, testInfo) + await expect(page).toHaveURL(new RegExp(`/dashboard/${entryId}$`)) + await expectFieldsRestored(page) + }) + + test('deleting the last entry returns to the empty dashboard', async ({ page }, testInfo) => { + const username = uniqueUsername('delete') + await registerUser(page, username, PASSWORD) + + // Registration auto-creates one entry. Open it via sidebar, then delete it — + // with zero entries remaining, /dashboard renders EmptyState. + await clickFirstEntry(page, testInfo) + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + + await page.getByTestId('delete-entry').click() + // DeleteEntryDialog uses base-ui AlertDialog → role="alertdialog". + const dialog = page.getByRole('alertdialog') + await expect(dialog).toBeVisible() + await page.getByTestId('delete-entry-confirm').click() + + await expect(page).toHaveURL(/\/dashboard$/) + // EmptyState renders the "Create your first note" button (testid + // `create-entry-empty`), unique to the empty dashboard — the sidebar's + // "No notes yet" label also shows, so the create button is the + // unambiguous EmptyState signal. + await expect(page.getByTestId('create-entry-empty')).toBeVisible() + }) + + test('multiple entries keep per-entry decrypted content when switching via the sidebar', async ({ + page, + }, testInfo) => { + const username = uniqueUsername('multi') + await registerUser(page, username, PASSWORD) + + // Entry 1 (auto-created): open via sidebar, type distinct values. + await clickFirstEntry(page, testInfo) + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + const entry1Id = entryIdFromUrl(page) + const entry1Values: FieldValues = { + title: 'Entry one title', + website: 'https://one.example.com', + email: 'one@example.com', + note: 'Note body ONE', + } + await fillAllFieldsAndAwaitSaved(page, entry1Values) + + // Entry 2: create via the sidebar "New note" button, type different values. + const entry2Id = await createEntry(page, testInfo) + const entry2Values: FieldValues = { + title: 'Entry two title', + website: 'https://two.example.com', + email: 'two@example.com', + note: 'Note body TWO', + } + await fillAllFieldsAndAwaitSaved(page, entry2Values) + + // Switch back to entry 1 via its sidebar nav item. Each entry's fields are + // keyed by entryId in the query cache, so switching routes must re-decrypt + // entry 1's ciphertext — not surface entry 2's. + await clickEntryNav(page, entry1Id, testInfo) + await expect(page).toHaveURL(new RegExp(`/dashboard/${entry1Id}$`)) + await expectFieldsRestored(page, entry1Values) + + // Switch to entry 2; assert its (different) values are the ones restored. + await clickEntryNav(page, entry2Id, testInfo) + await expect(page).toHaveURL(new RegExp(`/dashboard/${entry2Id}$`)) + await expectFieldsRestored(page, entry2Values) + }) + + test('editing the title field updates the sidebar nav item label', async ({ page }, testInfo) => { + const username = uniqueUsername('title') + await registerUser(page, username, PASSWORD) + + // After registration the sidebar shows the auto-created entry with the + // i18n fallback "Note 1" label (title field is still empty). + const sidebar = await getSidebar(page, testInfo) + await expect(sidebar.getByTestId('entry-nav-item').first()).toContainText('Note 1') + + // Click the entry to open it (Sheet closes on mobile after navigation). + await sidebar.getByTestId('entry-nav-item').first().click() + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + const entryId = entryIdFromUrl(page) + + // Type a title and let auto-save round-trip. useSaveField optimistically + // writes the plaintext into the field.detail cache onMutate, so + // EntryNavItem's useField(entryId, 'title') subscription re-renders with + // the new label once the debounced save fires — no reload needed. + const title = 'My sidebar title' + await page.getByTestId('field-input-title').fill(title) + await expect(page.getByTestId('field-card-title').getByTestId('save-indicator')).toHaveText('Saved') + + // Re-open sidebar on mobile (Sheet closed after navigation) to verify the + // nav label updated from the fallback "Note 1" to the typed title. + const navItem = await entryNavLocator(page, testInfo, entryId) + await expect(navItem).toContainText(title) + // The fallback label is gone — the title replaced it, not appended to it. + await expect(navItem).not.toContainText('Note 1') + }) +}) diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..19e32f2 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,58 @@ +import { execSync } from 'node:child_process' +import type { FullConfig } from '@playwright/test' + +/** + * Global E2E setup. Runs once before the suite. + * + * 1. Verifies local Supabase is reachable (fail fast — E2E needs Docker + + * `pnpm supabase:start`). + * 2. Verifies the env vars the suite depends on are present in `.env.local` + * (loaded by playwright.config.ts). + * 3. Resets the database (`supabase db reset`) so every run starts from a clean + * schema + seed with rate-limit counters cleared. This is the one slow reset + * (~10s); per-spec isolation is handled by the DB helpers. + */ +async function globalSetup(_config: FullConfig) { + const supabaseUrl = process.env.VITE_SUPABASE_URL + const anonKey = process.env.VITE_SUPABASE_ANON_KEY + + if (!supabaseUrl || !anonKey) { + throw new Error( + [ + 'E2E env vars missing.', + 'Copy .env.local.example to .env.local and fill VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY', + 'from `pnpm supabase:status`.', + ].join(' '), + ) + } + + await assertSupabaseReachable(supabaseUrl) + + console.info('[e2e] Resetting local Supabase database (supabase db reset)...') + try { + execSync('supabase db reset', { stdio: 'inherit' }) + } catch { + throw new Error( + '`supabase db reset` failed. Ensure Supabase is running (pnpm supabase:start) and the CLI is available.', + ) + } + console.info('[e2e] Database reset complete.') +} + +async function assertSupabaseReachable(supabaseUrl: string) { + const healthUrl = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/health` + try { + const res = await fetch(healthUrl, { signal: AbortSignal.timeout(5_000) }) + if (!res.ok) { + throw new Error(`health endpoint returned ${res.status}`) + } + } catch (err) { + throw new Error( + `Local Supabase is not reachable at ${supabaseUrl} (${(err as Error).message}). ` + + 'Start it with `pnpm supabase:start` (requires Docker).', + { cause: err }, + ) + } +} + +export default globalSetup diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..ae79679 --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,13 @@ +import { closeDb } from './helpers/db' + +/** + * Global E2E teardown. Runs once after the suite. + * + * Closes the pg connection pool so the Node process can exit cleanly + * (without it, the pool's idle client keeps the event loop alive). + */ +async function globalTeardown() { + await closeDb() +} + +export default globalTeardown diff --git a/e2e/helpers/db.ts b/e2e/helpers/db.ts new file mode 100644 index 0000000..887b913 --- /dev/null +++ b/e2e/helpers/db.ts @@ -0,0 +1,67 @@ +import { Pool, type PoolConfig, type QueryResult, type QueryResultRow } from 'pg' + +/** + * Direct-Postgres helper for E2E specs. Connects as the local `postgres` + * superuser (RLS bypass) to do what PostgREST cannot: truncate `auth.users` / + * `private.rate_limits` between specs, and read raw ciphertext / + * `auth.users.encrypted_password` for security assertions. + * + * Defaults to the local Supabase URL from `supabase status`. Override with + * `E2E_DB_URL` if your instance uses a non-default password. + */ + +const dbUrl = process.env.E2E_DB_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres' + +const poolConfig: PoolConfig = { + connectionString: dbUrl, + // The suite runs serially (workers: 1) — a single connection is enough and + // avoids idle-clients holding the local Postgres connection limit. + max: 1, +} + +let pool: Pool | null = null + +function getPool(): Pool { + if (!pool) pool = new Pool(poolConfig) + return pool +} + +/** + * Truncates all user data between spec files. `auth.users` is the root of the + * FK tree: `public.users` references it with ON DELETE CASCADE, and every + * public table (`login_salts`, `master_keys`, `field_keys`, `entries` → + * `encrypted_fields`, `recovery_keys`) cascades from it. Clearing + * `private.rate_limits` resets the pre-auth RPC counters (login salts, + * username check, recovery) so the next spec never trips them. Fast (<1s) — + * avoids a full `supabase db reset` per spec. + */ +export async function resetUserData(): Promise { + const client = await getPool().connect() + try { + await client.query('TRUNCATE "auth"."users" CASCADE') + await client.query('TRUNCATE "private"."rate_limits"') + } finally { + client.release() + } +} + +/** + * Runs an arbitrary SQL query with service-role access (RLS bypass) for + * security-spec DB assertions — e.g. inspecting `encrypted_fields.ciphertext` + * for a plaintext leak, or confirming `auth.users.encrypted_password` is + * neither the raw password nor the raw `authHash`. + */ +export async function queryRaw( + sql: string, + params?: unknown[], +): Promise['rows']> { + const result = await getPool().query(sql, params ?? []) + return result.rows +} + +/** Closes the connection pool. Call from a global teardown or suite afterAll. */ +export async function closeDb(): Promise { + if (!pool) return + await pool.end() + pool = null +} diff --git a/e2e/helpers/navigation.ts b/e2e/helpers/navigation.ts new file mode 100644 index 0000000..4c25f40 --- /dev/null +++ b/e2e/helpers/navigation.ts @@ -0,0 +1,180 @@ +import { expect, type Locator, type Page, type TestInfo } from '@playwright/test' + +/** + * Viewport-aware navigation helpers for E2E specs. + * + * The app renders the sidebar differently depending on viewport: + * - Desktop (≥ md): sidebar is always visible inside `