Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
coverage
test-results
playwright-report
*.local
!.env.local.example

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
6 changes: 4 additions & 2 deletions docs/implementation-plan/09-phase-9-polish.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Phase 9: Polish
# Phase 9: Polish

## Step 33 — Mobile Responsive Refinements ✅

Expand Down Expand Up @@ -88,7 +88,7 @@

---

## Step 36 — E2E Tests (Playwright)
## Step 36 — E2E Tests (Playwright)

**Goal:** Full end-to-end tests for critical user flows.

Expand All @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
141 changes: 141 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <header> — 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 <header> — 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$/)
})
})
Loading
Loading