From 5dfbae309c878c0f648c9be5e7d48988bd0c841b Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 19:31:06 +0200 Subject: [PATCH 1/8] test: scaffold Playwright E2E setup - add tsconfig.e2e.json so E2E files resolve @types/node --- .gitignore | 2 + README.md | 23 +++++ e2e/global-setup.ts | 58 ++++++++++++ eslint.config.js | 16 +++- package.json | 8 +- playwright.config.ts | 75 +++++++++++++++ pnpm-lock.yaml | 218 ++++++++++++++++++++++++++++++++++++++++++- tsconfig.e2e.json | 7 ++ tsconfig.json | 3 +- 9 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 e2e/global-setup.ts create mode 100644 playwright.config.ts create mode 100644 tsconfig.e2e.json 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/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/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/eslint.config.js b/eslint.config.js index 5f4d95c..de83f9d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,7 +23,10 @@ export default defineConfig([ rules: { 'react-refresh/only-export-components': [ 'warn', - { allowConstantExport: true, allowExportNames: ['Route', 'useTheme', 'useAuth', 'buttonVariants', 'queryClient'] }, + { + allowConstantExport: true, + allowExportNames: ['Route', 'useTheme', 'useAuth', 'buttonVariants', 'queryClient'], + }, ], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], }, @@ -34,4 +37,15 @@ export default defineConfig([ 'react-refresh/only-export-components': 'off', }, }, + { + // E2E tests run in Node (Playwright test runner), not the browser. + files: ['e2e/**/*.{ts,tsx}', 'playwright.config.ts'], + extends: [tseslint.configs.recommended, prettier], + languageOptions: { + globals: globals.node, + }, + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ]) diff --git a/package.json b/package.json index 03e792b..bedacf0 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "test:run": "vitest run", "test:ui": "vitest --ui", "coverage": "vitest run --coverage", - "typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.e2e.json", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "validate": "pnpm format && pnpm test:run && pnpm lint && pnpm typecheck" @@ -56,6 +58,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.6", "@testing-library/jest-dom": "^6.9.1", @@ -63,16 +66,19 @@ "@testing-library/user-event": "^14.6.1", "@types/argon2-browser": "^1.18.4", "@types/node": "^24.12.3", + "@types/pg": "^8.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.6", + "@vitest/ui": "4.1.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "pg": "^8.22.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", "supabase": "^2.100.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f2a8d3b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +/** + * Loads `.env.local` into `process.env` so Node-side test code (global setup, + * DB helpers, DB assertions) can read VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. + * Vite loads this file itself for the app build; Playwright does not, so we do + * it here once. Values already set in the real environment win. + */ +function loadEnvLocal() { + const envPath = path.resolve(process.cwd(), '.env.local') + try { + const content = readFileSync(envPath, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + const value = trimmed + .slice(eq + 1) + .trim() + .replace(/^["']|["']$/g, '') + if (!(key in process.env)) process.env[key] = value + } + } catch { + // No .env.local — rely on real environment variables. + } +} + +loadEnvLocal() + +const baseURL = 'http://localhost:4173' +const supabaseUrl = process.env.VITE_SUPABASE_URL ?? 'http://127.0.0.1:54321' + +export default defineConfig({ + testDir: './e2e', + testMatch: '*.spec.ts', + fullyParallel: false, + workers: 1, + retries: 1, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: [['list'], ['html', { open: 'never' }]], + globalSetup: './e2e/global-setup.ts', + + use: { + baseURL, + locale: 'en-US', + trace: 'on-first-retry', + // Real Argon2id derivations happen per auth op; don't let a slow worker trip actions. + actionTimeout: 30_000, + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'pnpm build && pnpm exec vite preview --port 4173 --strictPort', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + // Forward to the build so the production bundle talks to the same Supabase. + VITE_SUPABASE_URL: supabaseUrl, + VITE_SUPABASE_ANON_KEY: process.env.VITE_SUPABASE_ANON_KEY ?? '', + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 244483c..8c79174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.4.0(jiti@2.7.0)) + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tanstack/react-router-devtools': specifier: ^1.167.0 version: 1.167.0(@tanstack/react-router@1.170.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -120,6 +123,9 @@ importers: '@types/node': specifier: ^24.12.3 version: 24.12.4 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -132,6 +138,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.6 version: 4.1.6(vitest@4.1.6) + '@vitest/ui': + specifier: 4.1.6 + version: 4.1.6(vitest@4.1.6) eslint: specifier: ^10.3.0 version: 10.4.0(jiti@2.7.0) @@ -150,6 +159,9 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.2.0) + pg: + specifier: ^8.22.0 + version: 8.22.0 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -170,7 +182,7 @@ importers: version: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + version: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) packages: @@ -626,6 +638,14 @@ packages: '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/binding-android-arm64@1.0.1': resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1040,6 +1060,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1164,6 +1187,11 @@ packages: '@vitest/spy@4.1.6': resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + '@vitest/ui@4.1.6': + resolution: {integrity: sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==} + peerDependencies: + vitest: 4.1.6 + '@vitest/utils@4.1.6': resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} @@ -1701,6 +1729,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -1744,6 +1775,11 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2256,6 +2292,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2411,6 +2451,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2426,6 +2500,16 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -2434,6 +2518,22 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -2706,6 +2806,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -2723,6 +2827,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2834,6 +2942,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -3084,6 +3196,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3645,6 +3761,12 @@ snapshots: '@oxc-project/types@0.130.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@polka/url@1.0.0-next.29': {} + '@rolldown/binding-android-arm64@1.0.1': optional: true @@ -4004,6 +4126,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.12.4 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -4128,7 +4256,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + vitest: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) '@vitest/expect@4.1.6': dependencies: @@ -4166,6 +4294,17 @@ snapshots: '@vitest/spy@4.1.6': {} + '@vitest/ui@4.1.6(vitest@4.1.6)': + dependencies: + '@vitest/utils': 4.1.6 + fflate: 0.8.3 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/utils@4.1.6': dependencies: '@vitest/pretty-format': 4.1.6 @@ -4707,6 +4846,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -4756,6 +4897,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5168,6 +5312,8 @@ snapshots: minimist@1.2.8: {} + mrmime@2.0.1: {} + ms@2.1.3: {} msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3): @@ -5326,6 +5472,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5334,6 +5515,14 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -5345,6 +5534,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + powershell-utils@0.1.0: {} prelude-ls@1.2.1: {} @@ -5617,6 +5816,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -5628,6 +5833,8 @@ snapshots: source-map@0.6.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -5724,6 +5931,8 @@ snapshots: toidentifier@1.0.1: {} + totalist@3.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.30 @@ -5828,7 +6037,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)): + vitest@4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) @@ -5853,6 +6062,7 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) + '@vitest/ui': 4.1.6(vitest@4.1.6) jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw @@ -5911,6 +6121,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..f441870 --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.e2e.tsbuildinfo" + }, + "include": ["e2e", "playwright.config.ts"] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c452f43..6f8d848 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.e2e.json" } ] } \ No newline at end of file From 8f9d328781a811c236418b50e5c4c053e8ba4513 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 19:35:04 +0200 Subject: [PATCH 2/8] test: add Playwright E2E DB and user helpers --- e2e/helpers/db.ts | 67 ++++++++++++++++++++++++++ e2e/helpers/users.ts | 110 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 e2e/helpers/db.ts create mode 100644 e2e/helpers/users.ts diff --git a/e2e/helpers/db.ts b/e2e/helpers/db.ts new file mode 100644 index 0000000..7b4d0ac --- /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/users.ts b/e2e/helpers/users.ts new file mode 100644 index 0000000..09f11f9 --- /dev/null +++ b/e2e/helpers/users.ts @@ -0,0 +1,110 @@ +import { randomBytes } from 'node:crypto' + +import { expect, type Page } from '@playwright/test' + +/** + * UI-driving helpers for E2E specs. Each helper drives the real production + * flow through the rendered DOM (no API shortcuts) so Argon2id actually runs + * in the worker and the full crypto path is exercised end-to-end. + * + * Selectors target stable attributes (input ids, dialog roles, English i18n + * button labels, and data-testids). + */ + +export interface RegisteredUser { + username: string + password: string + mnemonic: string +} + +/** DB CHECK constraint is `^[a-zA-Z0-9_]{3,32}$`. */ +const USERNAME_MAX = 32 + +let counter = 0 + +/** + * Returns a unique username that fits `^[a-zA-Z0-9_]{3,32}$`, won't collide + * with seed users (`testuser`/`alice`), and is unique across specs in a run. + * The prefix is stripped of any character the DB CHECK rejects (anything + * outside [a-zA-Z0-9_]) so callers can pass labels like `rt-edit`. + */ +export function uniqueUsername(prefix: string): string { + counter += 1 + const safePrefix = prefix.replace(/[^a-zA-Z0-9_]/g, '') + const suffix = randomBytes(3).toString('hex') + return `e2e_${safePrefix}_${suffix}${counter}`.slice(0, USERNAME_MAX) +} + +/** + * Loads the SPA from a fresh context. The single `page.goto` in the suite: + * Playwright starts each test at `about:blank`, so the app must be loaded once + * before any in-app navigation. Lands on `/` (LandingPage) when unauthenticated; + * every subsequent navigation is an in-app click so KeyVault and the crypto + * store survive across routes. + */ +export async function loadApp(page: Page): Promise { + await page.goto('/') +} + +/** + * Registers a fresh user through the real `/register` flow and captures the + * 12-word mnemonic shown in MnemonicDialog. Returns the credentials plus the + * mnemonic so recovery/crypto specs can reuse them. Leaves the page on + * `/dashboard`. + */ +export async function registerUser(page: Page, username: string, password: string): Promise { + // Bootstrap the SPA, then navigate to /register via the landing hero CTA + // link (in-app, no full reload). CtaButtons renders in both HeroSection and + // SecurityBanner, so .first() targets the hero instance. + await loadApp(page) + await page.getByTestId('landing-cta-register').first().click() + await page.waitForURL('**/register') + + await page.locator('#username').fill(username) + await page.locator('#password').fill(password) + await page.locator('#confirm-password').fill(password) + + // Click auto-waits for the button to be enabled — the username-availability + // check must resolve to "available" first (disabled while "checking"/"taken"). + await page.locator('button[type="submit"]').click() + + // Registration runs a real Argon2id derivation (~1s) before the dialog opens. + const dialog = page.getByRole('dialog') + await dialog.waitFor({ state: 'visible' }) + + const mnemonic = await readMnemonic(dialog) + + // Acknowledge + Continue → navigates to /dashboard. base-ui's Checkbox routes + // `id` to a visually-hidden native at position:fixed top-left, so + // `#id` clicks under the dialog overlay. Target the visible + // by testid instead. + await dialog.getByTestId('mnemonic-acknowledge').check() + await dialog.getByRole('button', { name: 'Continue', exact: true }).click() + await page.waitForURL('**/dashboard') + + return { username, password, mnemonic } +} + +/** Logs in through the real `/login` flow. */ +export async function login(page: Page, username: string, password: string): Promise { + await page.goto('/login') + + await page.locator('#username').fill(username) + await page.locator('#password').fill(password) + await page.locator('button[type="submit"]').click() + + // Real Argon2id derivation + envelope unwrap. + await page.waitForURL('**/dashboard') +} + +/** + * Reads the 12-word mnemonic from MnemonicDialog, stripping the "N." index + * prefix each word cell renders. Targets the word cells' `font-mono` class + * (the only such elements in the dialog). + */ +async function readMnemonic(dialog: ReturnType): Promise { + const wordCells = dialog.locator('.font-mono') + await expect(wordCells).toHaveCount(12) + const texts = await wordCells.allInnerTexts() + return texts.map((text) => text.replace(/^\s*\d+\.\s*/, '').trim()).join(' ') +} From 21dcd5b1e38e2e558479d0a38cd1c0a9742b0adb Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 7 Jul 2026 19:38:16 +0200 Subject: [PATCH 3/8] test: add data-testid selectors across UI for E2E tests --- src/app/layouts/EntryNavItem.tsx | 4 +++ src/app/layouts/MobileNav.tsx | 1 + src/app/layouts/Sidebar.tsx | 19 +++++++++++--- src/features/auth/ui/ChangePasswordDialog.tsx | 1 + src/features/auth/ui/LoginPage.tsx | 1 + src/features/auth/ui/MnemonicDialog.tsx | 13 +++++++--- src/features/auth/ui/MnemonicInput.tsx | 1 + src/features/auth/ui/RecoverPage.tsx | 2 ++ .../auth/ui/RegenerateMnemonicDialog.tsx | 1 + src/features/auth/ui/RegisterPage.tsx | 1 + src/features/auth/ui/VerifyMnemonicDialog.tsx | 10 +++++-- src/features/fields/ui/CreateEntryButton.tsx | 1 + src/features/fields/ui/DashboardPage.tsx | 2 +- src/features/fields/ui/DeleteEntryDialog.tsx | 10 ++++++- src/features/fields/ui/FieldCard.tsx | 1 + src/features/fields/ui/InputField.tsx | 1 + src/features/fields/ui/NoteField.tsx | 1 + .../fields/ui/RotateFieldKeyDialog.tsx | 6 +++-- src/features/fields/ui/SaveIndicator.tsx | 26 +++++++++++++++---- src/features/landing/ui/CtaButtons.tsx | 12 +++++++-- src/features/settings/ui/AccountSection.tsx | 8 +++++- .../settings/ui/KeyManagementSubsection.tsx | 14 ++++++++-- src/features/settings/ui/SecuritySection.tsx | 6 +++-- src/features/settings/ui/SettingsItem.tsx | 4 ++- src/features/vault/ui/VaultUnlockDialog.tsx | 1 + src/shared/ui/PasswordConfirmDialog.tsx | 10 ++++++- src/shared/ui/form/SubmitButton.tsx | 18 +++++++++++-- 27 files changed, 145 insertions(+), 30 deletions(-) diff --git a/src/app/layouts/EntryNavItem.tsx b/src/app/layouts/EntryNavItem.tsx index a32c0e1..d0dcc12 100644 --- a/src/app/layouts/EntryNavItem.tsx +++ b/src/app/layouts/EntryNavItem.tsx @@ -22,6 +22,8 @@ export function EntryNavItem({ entryId, index, isVaultLocked, isActive, onClick, return ( diff --git a/src/app/layouts/Sidebar.tsx b/src/app/layouts/Sidebar.tsx index ad0e35b..65dd042 100644 --- a/src/app/layouts/Sidebar.tsx +++ b/src/app/layouts/Sidebar.tsx @@ -46,7 +46,13 @@ function Sidebar({ onClose, onLogout, className }: SidebarProps) {
{onClose && ( - )} @@ -92,14 +98,14 @@ function Sidebar({ onClose, onLogout, className }: SidebarProps) {
{/* User info */} {user && ( -
+
{user.username}
)} {/* Logout button */} - @@ -108,7 +114,12 @@ function Sidebar({ onClose, onLogout, className }: SidebarProps) { {/* Vault lock button */} {/* Settings */} - + {t('common:nav.settings')} diff --git a/src/features/auth/ui/ChangePasswordDialog.tsx b/src/features/auth/ui/ChangePasswordDialog.tsx index 86f4cc1..830c226 100644 --- a/src/features/auth/ui/ChangePasswordDialog.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.tsx @@ -87,6 +87,7 @@ function ChangePasswordDialog() { isSubmitting={isSubmitting} submitLabel={t('changePassword.submit')} submittingLabel={t('changePassword.submitting')} + dataTestId="change-password-submit" /> diff --git a/src/features/auth/ui/LoginPage.tsx b/src/features/auth/ui/LoginPage.tsx index de162b6..947c2f1 100644 --- a/src/features/auth/ui/LoginPage.tsx +++ b/src/features/auth/ui/LoginPage.tsx @@ -65,6 +65,7 @@ function LoginPage({ redirectUrl }: LoginPageProps) { isSubmitting={isSubmitting} submitLabel={t('login.submit')} submittingLabel={t('login.submitting')} + dataTestId="login-submit" /> diff --git a/src/features/auth/ui/MnemonicDialog.tsx b/src/features/auth/ui/MnemonicDialog.tsx index 66d382e..ee8773b 100644 --- a/src/features/auth/ui/MnemonicDialog.tsx +++ b/src/features/auth/ui/MnemonicDialog.tsx @@ -78,7 +78,7 @@ function MnemonicDialog({ open, mnemonic, onContinue }: MnemonicDialogProps) {

{t('mnemonic.warning')}

-
+
{words.map((word, index) => (
{index + 1}. @@ -88,7 +88,7 @@ function MnemonicDialog({ open, mnemonic, onContinue }: MnemonicDialogProps) {
- @@ -99,14 +99,19 @@ function MnemonicDialog({ open, mnemonic, onContinue }: MnemonicDialogProps) {
- +
- diff --git a/src/features/auth/ui/MnemonicInput.tsx b/src/features/auth/ui/MnemonicInput.tsx index 7ec34cf..cc794fd 100644 --- a/src/features/auth/ui/MnemonicInput.tsx +++ b/src/features/auth/ui/MnemonicInput.tsx @@ -125,6 +125,7 @@ function MnemonicInput({ value, onChange, disabled, error, onValidityChange }: M onPaste={(e) => handlePaste(index, e)} disabled={disabled} aria-label={`Word ${index + 1}`} + data-testid={`mnemonic-word-${index + 1}`} className={cn('font-mono', isInvalid && 'border-destructive')} /> ) diff --git a/src/features/auth/ui/RecoverPage.tsx b/src/features/auth/ui/RecoverPage.tsx index 342a7d6..0c19f63 100644 --- a/src/features/auth/ui/RecoverPage.tsx +++ b/src/features/auth/ui/RecoverPage.tsx @@ -138,6 +138,7 @@ function RecoverPage() { submitLabel={t('recover.submit')} submittingLabel={t('recover.submitting')} disabled={!mnemonicValid} + dataTestId="recover-submit" /> @@ -161,6 +162,7 @@ function RecoverPage() { isSubmitting={isSubmittingStep2} submitLabel={t('recover.setPassword')} submittingLabel={t('recover.settingPassword')} + dataTestId="recover-set-password" /> diff --git a/src/features/auth/ui/RegenerateMnemonicDialog.tsx b/src/features/auth/ui/RegenerateMnemonicDialog.tsx index 50c3901..0e06cfe 100644 --- a/src/features/auth/ui/RegenerateMnemonicDialog.tsx +++ b/src/features/auth/ui/RegenerateMnemonicDialog.tsx @@ -45,6 +45,7 @@ function RegenerateMnemonicDialog() { description={t('regenerateMnemonic.description')} submitLabel={t('regenerateMnemonic.submit')} isSubmittingLabel={t('regenerateMnemonic.submitting')} + submitTestId="regenerate-mnemonic-submit" /> {mnemonic && ( diff --git a/src/features/auth/ui/RegisterPage.tsx b/src/features/auth/ui/RegisterPage.tsx index 6d74a84..fb323cc 100644 --- a/src/features/auth/ui/RegisterPage.tsx +++ b/src/features/auth/ui/RegisterPage.tsx @@ -90,6 +90,7 @@ function RegisterPage({ onSubmit }: RegisterPageProps) { submitLabel={t('register.submit')} submittingLabel={t('register.submitting')} disabled={isSubmitDisabled} + dataTestId="register-submit" /> diff --git a/src/features/auth/ui/VerifyMnemonicDialog.tsx b/src/features/auth/ui/VerifyMnemonicDialog.tsx index 24a719c..92d419b 100644 --- a/src/features/auth/ui/VerifyMnemonicDialog.tsx +++ b/src/features/auth/ui/VerifyMnemonicDialog.tsx @@ -82,10 +82,16 @@ function VerifyMnemonicDialog() { disabled={isSubmitting} />
- -
diff --git a/src/features/fields/ui/CreateEntryButton.tsx b/src/features/fields/ui/CreateEntryButton.tsx index 76b17d6..58818c0 100644 --- a/src/features/fields/ui/CreateEntryButton.tsx +++ b/src/features/fields/ui/CreateEntryButton.tsx @@ -36,6 +36,7 @@ function CreateEntryButton({ onCreated, size = 'icon-sm', className }: CreateEnt onClick={handleCreateEntry} aria-label={t('create')} disabled={isVaultLocked || createEntry.isPending} + data-testid="create-entry" > diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 35413b6..5894eb7 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -137,7 +137,7 @@ function EmptyState({ onCreateEntry, lockedFallback }: { onCreateEntry: () => vo {t('empty')} - diff --git a/src/features/fields/ui/DeleteEntryDialog.tsx b/src/features/fields/ui/DeleteEntryDialog.tsx index e1a2a9e..5019a68 100644 --- a/src/features/fields/ui/DeleteEntryDialog.tsx +++ b/src/features/fields/ui/DeleteEntryDialog.tsx @@ -35,7 +35,14 @@ function DeleteEntryDialog({ entryId }: { entryId: string }) { return ( } + render={ + diff --git a/src/features/landing/ui/CtaButtons.tsx b/src/features/landing/ui/CtaButtons.tsx index 8a2ec52..4977044 100644 --- a/src/features/landing/ui/CtaButtons.tsx +++ b/src/features/landing/ui/CtaButtons.tsx @@ -9,10 +9,18 @@ function CtaButtons() { return (
- + {t('hero.cta')} - + {t('hero.login')}
diff --git a/src/features/settings/ui/AccountSection.tsx b/src/features/settings/ui/AccountSection.tsx index 6f433cc..8460b60 100644 --- a/src/features/settings/ui/AccountSection.tsx +++ b/src/features/settings/ui/AccountSection.tsx @@ -29,10 +29,16 @@ function AccountSection() { useChangePasswordDialogStore.getState().open()} /> - +
) diff --git a/src/features/settings/ui/KeyManagementSubsection.tsx b/src/features/settings/ui/KeyManagementSubsection.tsx index 49f3dce..db92dca 100644 --- a/src/features/settings/ui/KeyManagementSubsection.tsx +++ b/src/features/settings/ui/KeyManagementSubsection.tsx @@ -32,7 +32,10 @@ function KeyManagementSubsection() { return ( - + {t('security.keyManagement')} @@ -51,7 +54,13 @@ function KeyManagementSubsection() { {t('keyRotation.version', { version: versionFor(fieldKeys, fieldName) })} -
@@ -63,6 +72,7 @@ function KeyManagementSubsection() { className="mt-2 w-full" disabled={isVaultLocked} onClick={() => openDialog({ fieldName: null })} + data-testid="settings-rotate-all-keys" > {t('keyRotation.rotateAll')} diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index 9e6b4a0..7a20c3a 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -11,15 +11,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store' import { KeyManagementSubsection } from '@/features/settings/ui/KeyManagementSubsection' -const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ +const ITEMS: { icon: LucideIcon; labelKey: string; testId: string; onClick?: () => void }[] = [ { icon: ScanEye, labelKey: 'security.verifySeedPhrase', + testId: 'settings-verify-mnemonic', onClick: () => useVerifyMnemonicDialogStore.getState().open(), }, { icon: ShieldCheck, labelKey: 'security.seedPhrase', + testId: 'settings-regenerate-mnemonic', onClick: () => useRegenerateMnemonicDialogStore.getState().open(), }, ] @@ -96,7 +98,7 @@ function SecuritySection() { {ITEMS.map((item, i) => ( {i > 0 && } - + ))} diff --git a/src/features/settings/ui/SettingsItem.tsx b/src/features/settings/ui/SettingsItem.tsx index 590ced7..199b9da 100644 --- a/src/features/settings/ui/SettingsItem.tsx +++ b/src/features/settings/ui/SettingsItem.tsx @@ -5,11 +5,12 @@ import { cn } from '@/shared/lib/utils' interface SettingsItemProps { icon: LucideIcon label: string + testId?: string onClick?: () => void variant?: 'default' | 'destructive' } -function SettingsItem({ icon: Icon, label, onClick, variant = 'default' }: SettingsItemProps) { +function SettingsItem({ icon: Icon, label, testId, onClick, variant = 'default' }: SettingsItemProps) { const isDestructive = variant === 'destructive' if (onClick) { @@ -20,6 +21,7 @@ function SettingsItem({ icon: Icon, label, onClick, variant = 'default' }: Setti 'hover:bg-muted/50 -mx-1 flex w-full cursor-pointer items-center justify-between rounded-md px-1 py-2 text-left', isDestructive && 'text-destructive hover:bg-destructive/10', )} + data-testid={testId} onClick={onClick} > diff --git a/src/features/vault/ui/VaultUnlockDialog.tsx b/src/features/vault/ui/VaultUnlockDialog.tsx index fe5d16f..4471f84 100644 --- a/src/features/vault/ui/VaultUnlockDialog.tsx +++ b/src/features/vault/ui/VaultUnlockDialog.tsx @@ -32,6 +32,7 @@ function VaultUnlockDialog() { description={t('vaultUnlockDialog.description')} submitLabel={t('vaultUnlockDialog.submit')} isSubmittingLabel={t('vaultUnlockDialog.submitting')} + submitTestId="vault-unlock-submit" /> ) } diff --git a/src/shared/ui/PasswordConfirmDialog.tsx b/src/shared/ui/PasswordConfirmDialog.tsx index e89b425..7936079 100644 --- a/src/shared/ui/PasswordConfirmDialog.tsx +++ b/src/shared/ui/PasswordConfirmDialog.tsx @@ -24,6 +24,8 @@ interface PasswordConfirmDialogProps { description: string submitLabel: string isSubmittingLabel: string + /** Stable selector for the submit button (rendered as `data-testid`). */ + submitTestId?: string } function PasswordConfirmDialog({ @@ -35,6 +37,7 @@ function PasswordConfirmDialog({ description, submitLabel, isSubmittingLabel, + submitTestId, }: PasswordConfirmDialogProps) { const { t } = useTranslation('auth') const [error, setError] = useState(null) @@ -87,7 +90,12 @@ function PasswordConfirmDialog({ {...register('password')} /> - + diff --git a/src/shared/ui/form/SubmitButton.tsx b/src/shared/ui/form/SubmitButton.tsx index f9940fb..35a9288 100644 --- a/src/shared/ui/form/SubmitButton.tsx +++ b/src/shared/ui/form/SubmitButton.tsx @@ -8,11 +8,25 @@ interface SubmitButtonProps { submittingLabel: string disabled?: boolean className?: string + /** Stable selector for E2E tests (rendered as `data-testid`). */ + dataTestId?: string } -function SubmitButton({ isSubmitting, submitLabel, submittingLabel, disabled, className }: SubmitButtonProps) { +function SubmitButton({ + isSubmitting, + submitLabel, + submittingLabel, + disabled, + className, + dataTestId, +}: SubmitButtonProps) { return ( - From 0646df050193072cfd497b3e027092e8a139a04d Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 13:29:51 +0200 Subject: [PATCH 4/8] test: add Playwright E2E specs for auth, fields, crypto, realtime and security flows --- e2e/auth.spec.ts | 140 +++++++++++ e2e/crypto.spec.ts | 267 +++++++++++++++++++++ e2e/fields.spec.ts | 231 ++++++++++++++++++ e2e/realtime.spec.ts | 160 ++++++++++++ e2e/security.spec.ts | 159 ++++++++++++ src/features/auth/ui/RegisterPage.test.tsx | 27 ++- 6 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/crypto.spec.ts create mode 100644 e2e/fields.spec.ts create mode 100644 e2e/realtime.spec.ts create mode 100644 e2e/security.spec.ts diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..5fa47eb --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test' + +import { resetUserData } from './helpers/db' +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 }) => { + 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 page.getByTestId('logout-button').click() + 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 }) => { + 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 page.getByTestId('logout-button').click() + await expect(page).toHaveURL(/\/login$/) + await login(page, username, PASSWORD) + + // Login auto-unlocks, so lock manually to reach the unlock dialog. + await page.getByRole('button', { name: 'Lock vault', exact: true }).click() + // 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 }) => { + 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 page.getByTestId('logout-button').click() + await expect(page).toHaveURL(/\/login$/) + await login(page, username, PASSWORD) + + // Login auto-unlocks, so lock manually to reach the unlock dialog. + await page.getByRole('button', { name: 'Lock vault', exact: true }).click() + + // 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 }) => { + const username = uniqueUsername('logout') + await registerUser(page, username, PASSWORD) + + await page.getByTestId('logout-button').click() + + // 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 }) => { + const username = uniqueUsername('taken') + await registerUser(page, username, PASSWORD) + + // Log out so /register renders (the _public guard redirects authed users + // to /dashboard). + await page.getByTestId('logout-button').click() + 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..948e781 --- /dev/null +++ b/e2e/crypto.spec.ts @@ -0,0 +1,267 @@ +import { expect, test, type Page } from '@playwright/test' + +import { resetUserData } from './helpers/db' +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]) + } +} + +/** + * Drives the sidebar "New note" button to create an entry and returns the + * entryId from the URL so the rotation test can re-navigate after rotating. + */ +async function createEntry(page: Page): Promise { + // `create-entry` renders on both the desktop sidebar and the md:hidden + // mobile nav; .first() targets the sidebar variant. + await page.getByTestId('create-entry').first().click() + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + const match = page.url().match(/\/dashboard\/([^/]+)$/) + if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) + return match[1] +} + +test.describe('crypto', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + test('change password: old password fails, new password unlocks the vault', async ({ page }) => { + 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 page.getByTestId('nav-settings').first().click() + 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 page.getByTestId('logout-button').click() + 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 }) => { + const username = uniqueUsername('changepwbad') + await registerUser(page, username, PASSWORD) + + // Settings → Change password dialog (in-app nav keeps the vault unlocked). + await page.getByTestId('nav-settings').first().click() + 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 page.getByTestId('entry-nav-item').first().click() + 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 }) => { + 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 page.getByTestId('nav-settings').first().click() + 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 }) => { + 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 page.getByTestId('logout-button').click() + 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 page.getByTestId('logout-button').click() + 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 }) => { + 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) + 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 page.getByTestId('nav-settings').first().click() + 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 page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first().click() + 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..36ff5e0 --- /dev/null +++ b/e2e/fields.spec.ts @@ -0,0 +1,231 @@ +import { expect, test, type Page } from '@playwright/test' + +import { resetUserData } from './helpers/db' +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() + }) + + /** Captures the entryId from the current /dashboard/$entryId URL. */ + function entryIdFromUrl(page: Page): string { + const match = page.url().match(/\/dashboard\/([^/]+)$/) + if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) + return match[1] + } + + /** + * Drives the sidebar "New note" button to create an entry and returns the + * entryId from the URL so later reload/login steps can re-navigate to it. + */ + async function createEntry(page: Page): Promise { + // .first() targets the desktop sidebar variant (mobile nav is hidden). + const urlBefore = page.url() + await page.getByTestId('create-entry').first().click() + // If already viewing an entry, toHaveURL(regex) would resolve instantly and + // capture the wrong id — poll until the URL actually changes. + await expect + .poll( + () => { + const url = page.url() + return /\/dashboard\/[^/]+$/.test(url) && url !== urlBefore ? url : null + }, + { timeout: 10000 }, + ) + .not.toBeNull() + return entryIdFromUrl(page) + } + + /** + * 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 }) => { + const username = uniqueUsername('fields') + await registerUser(page, username, PASSWORD) + + const entryId = await createEntry(page) + 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 }) => { + const username = uniqueUsername('relogin') + await registerUser(page, username, PASSWORD) + + const entryId = await createEntry(page) + 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 page.getByTestId('logout-button').click() + await expect(page).toHaveURL(/\/login$/) + + await login(page, username, PASSWORD) + await page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first().click() + await expect(page).toHaveURL(new RegExp(`/dashboard/${entryId}$`)) + await expectFieldsRestored(page) + }) + + test('deleting the last entry returns to the empty dashboard', async ({ page }) => { + const username = uniqueUsername('delete') + await registerUser(page, username, PASSWORD) + + // Registration auto-creates one entry. Open it, then delete it — with zero + // entries remaining, /dashboard renders EmptyState (not DashboardWelcome). + // `entry-nav-item` renders on both desktop sidebar and md:hidden mobile + // nav; .first() targets the sidebar variant. + await page.getByTestId('entry-nav-item').first().click() + 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 }) => { + const username = uniqueUsername('multi') + await registerUser(page, username, PASSWORD) + + // Entry 1 (auto-created): open via sidebar, type distinct values. + await page.getByTestId('entry-nav-item').first().click() + 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) + 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. .first() scopes to the + // sidebar variant (entry-nav-item also renders in the mobile nav). + await page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entry1Id}"]`).first().click() + 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 page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entry2Id}"]`).first().click() + 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 }) => { + const username = uniqueUsername('title') + await registerUser(page, username, PASSWORD) + + // Open the auto-created entry; its sidebar item initially shows the i18n + // fallback "Note 1" because the title field is empty (EntryNavItem falls + // back to entryLabel when the decrypted title is empty). + await page.getByTestId('entry-nav-item').first().click() + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + const entryId = entryIdFromUrl(page) + const navItem = page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first() + await expect(navItem).toContainText('Note 1') + + // 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') + + 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/realtime.spec.ts b/e2e/realtime.spec.ts new file mode 100644 index 0000000..7a375c3 --- /dev/null +++ b/e2e/realtime.spec.ts @@ -0,0 +1,160 @@ +import { expect, test, type Browser, type Page } from '@playwright/test' + +import { resetUserData } from './helpers/db' +import { login, registerUser, uniqueUsername } from './helpers/users' + +/** + * Realtime sync E2E — cross-session propagation against the production preview + * build, with real crypto + Supabase Realtime postgres_changes broadcasts. + * + * Two browser contexts (= two devices/tabs) log in as the SAME user: + * - A registers the user (real Argon2id) and drives the mutating flow. + * - B logs in via the real /login flow (real Argon2id + vault unlock) and + * asserts the change propagates WITHOUT a reload — the realtime channel + * delivered it, the query was invalidated, and the field re-decrypted with + * the key already in B's vault. + * + * `beforeEach` truncates `auth.users` + `private.rate_limits` so neither + * context's `get_login_salts` pre-auth RPC trips the shared-IP limiter. + * + * Subscription ordering: Supabase `postgres_changes` does NOT replay events + * that occur before a channel is SUBSCRIBED, so B must be subscribed before A + * mutates. B's /login + navigation takes ~2s, during which the channel + * connects; a short wait before A acts plus auto-retry on the cross-session + * assertion absorbs delivery latency. + */ + +const PASSWORD = 'TestPass123!' + +/** Auto-retry budget for cross-session assertions: realtime delivery + refetch + decrypt. */ +const CROSS_SESSION_TIMEOUT = 20_000 + +/** Safety margin so B's realtime channel is SUBSCRIBED before A mutates. */ +const SUBSCRIBE_SETTLE_MS = 1500 + +/** + * Opens a second browser context (= a second device) and logs in as the same + * user via the real /login flow. Returns B's page and a `close` that tears the + * context down. Each test creates and closes its own second context — Playwright + * only auto-closes the fixture context (A). + */ +async function openSecondSession( + browser: Browser, + username: string, + password: string, +): Promise<{ pageB: Page; close: () => Promise }> { + const contextB = await browser.newContext() + const pageB = await contextB.newPage() + await login(pageB, username, password) + return { pageB, close: () => contextB.close() } +} + +/** Captures the entryId from the current /dashboard/$entryId URL. */ +function entryIdFromUrl(page: Page): string { + const match = page.url().match(/\/dashboard\/([^/]+)$/) + if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) + return match[1] +} + +test.describe('realtime', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + test('a field edit in session A appears in session B without a reload', async ({ page: pageA, browser }) => { + const username = uniqueUsername('rtedit') + await registerUser(pageA, username, PASSWORD) + // Registration auto-creates entry #1; open it in A so A has a field to edit. + await pageA.getByTestId('entry-nav-item').first().click() + await expect(pageA).toHaveURL(/\/dashboard\/[^/]+$/) + const entryId = entryIdFromUrl(pageA) + + const sessionB = await openSecondSession(browser, username, PASSWORD) + try { + const { pageB } = sessionB + // B navigates to the SAME entry; its note field is empty (fresh entry). + await pageB.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first().click() + await expect(pageB).toHaveURL(new RegExp(`/dashboard/${entryId}$`)) + await expect(pageB.getByTestId('field-input-note')).toHaveValue('') + // Let B's realtime channel finish subscribing before A mutates. + await pageB.waitForTimeout(SUBSCRIBE_SETTLE_MS) + + // A types and auto-saves (debounce + network + server write + broadcast). + const noteValue = `Realtime edit from A — ${entryId.slice(0, 6)}` + await pageA.getByTestId('field-input-note').fill(noteValue) + await expect(pageA.getByTestId('field-card-note').getByTestId('save-indicator')).toHaveText('Saved') + + // B's onFieldChange: not an echo (B never saved), no pending save → + // invalidate field.detail → refetch → re-decrypt with B's note key → the + // input updates in place, no reload. + await expect(pageB.getByTestId('field-input-note')).toHaveValue(noteValue, { + timeout: CROSS_SESSION_TIMEOUT, + }) + } finally { + await sessionB.close() + } + }) + + test('an entry created in session A appears in session B sidebar', async ({ page: pageA, browser }) => { + const username = uniqueUsername('rtcreate') + await registerUser(pageA, username, PASSWORD) + // A is on /dashboard with the single auto-created entry. + + const sessionB = await openSecondSession(browser, username, PASSWORD) + try { + const { pageB } = sessionB + // B lands on /dashboard; let its realtime channel subscribe before A acts. + await pageB.waitForTimeout(SUBSCRIBE_SETTLE_MS) + + // A creates a second entry via the sidebar "New note" button. + await pageA.getByTestId('create-entry').first().click() + await expect(pageA).toHaveURL(/\/dashboard\/[^/]+$/) + const newEntryId = entryIdFromUrl(pageA) + + // B's onEntryChange (INSERT) invalidates the entry list → sidebar + // refetches → the new entry-nav-item renders. Assert by the specific + // entryId so a pre-existing item can't satisfy the assertion. .first() + // scopes to the sidebar variant (entry-nav-item also renders in the + // mobile nav, so without .first() it matches 2 elements and trips strict + // mode). + await expect(pageB.locator(`[data-testid="entry-nav-item"][data-entry-id="${newEntryId}"]`).first()).toBeVisible({ + timeout: CROSS_SESSION_TIMEOUT, + }) + } finally { + await sessionB.close() + } + }) + + test('an entry deleted in session A disappears from session B sidebar', async ({ page: pageA, browser }) => { + const username = uniqueUsername('rtdelete') + await registerUser(pageA, username, PASSWORD) + // Open the auto-created entry in A so it can be deleted; capture its id. + await pageA.getByTestId('entry-nav-item').first().click() + await expect(pageA).toHaveURL(/\/dashboard\/[^/]+$/) + const entryId = entryIdFromUrl(pageA) + + const sessionB = await openSecondSession(browser, username, PASSWORD) + try { + const { pageB } = sessionB + // B sees the entry in its sidebar before the delete. .first() scopes to + // the sidebar variant (see the create test for the dup reason). + await expect(pageB.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first()).toBeVisible() + await pageB.waitForTimeout(SUBSCRIBE_SETTLE_MS) + + // A deletes the entry through the real DeleteEntryDialog. + await pageA.getByTestId('delete-entry').click() + const dialog = pageA.getByRole('alertdialog') + await expect(dialog).toBeVisible() + await pageA.getByTestId('delete-entry-confirm').click() + await expect(pageA).toHaveURL(/\/dashboard$/) + + // B's onEntryChange (DELETE) invalidates the entry list + removes field + // queries for that entry → the sidebar item disappears. + await expect(pageB.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`)).toHaveCount(0, { + timeout: CROSS_SESSION_TIMEOUT, + }) + } finally { + await sessionB.close() + } + }) +}) diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts new file mode 100644 index 0000000..95a08bc --- /dev/null +++ b/e2e/security.spec.ts @@ -0,0 +1,159 @@ +import { expect, test, type Page } from '@playwright/test' + +import { queryRaw, resetUserData } from './helpers/db' +import { registerUser, uniqueUsername } from './helpers/users' + +/** + * Security E2E — RLS isolation and at-rest confidentiality, against the + * production preview build with real crypto. + * + * Two concerns, per docs/e2ee-plan.md: + * 1. RLS: a second authenticated user cannot read another user's + * `encrypted_fields` by id. Query PostgREST as user B (with B's real + * Supabase access token, grabbed from the browser session after a UI + * login) for one of A's entry rows — RLS must filter it to an empty set. + * 2. At-rest confidentiality: the server never stores plaintext. Via the + * service-role `pg` connection (RLS bypass) inspect A's + * `encrypted_fields.ciphertext` and assert it does not contain the typed + * plaintext substring; inspect A's `auth.users.encrypted_password` and + * assert it is neither the plaintext password nor the raw 64-hex-char + * `authHash` (Supabase bcrypt-hashes the supplied authHash). + * + * Each test registers fresh users (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 trips the + * shared-IP limiter. + */ + +const PASSWORD = 'TestPass123!' + +/** Distinctive plaintext typed into A's title field; used as the leak canary. */ +const TITLE_PLAINTEXT = 'TopSecretLeakCanary-Title' + +test.describe('security', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + /** + * Drives the sidebar "New note" button to create an entry and returns the + * entryId from the URL so the RLS query and DB assertions can target A's row. + */ + async function createEntry(page: Page): Promise { + // `create-entry` renders on both the desktop sidebar and the md:hidden + // mobile nav; .first() targets the sidebar variant. + await page.getByTestId('create-entry').first().click() + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + const match = page.url().match(/\/dashboard\/([^/]+)$/) + if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) + return match[1] + } + + /** + * Reads the current Supabase session's access token from the page's + * localStorage. supabase-js v2 persists the session under a + * `sb--auth-token` key; the access token is what the PostgREST + * `Authorization: Bearer` header expects. Used to issue a server-side request + * carrying B's identity without re-deriving the authHash in Node (which would + * duplicate the split-KDF flow the suite exercises through the UI). + */ + async function extractAccessToken(page: Page): Promise { + const storage = await page.evaluate(() => ({ ...localStorage })) + const tokenKey = Object.keys(storage).find((key) => /^sb-.*-auth-token$/.test(key)) + if (!tokenKey) { + throw new Error(`No Supabase auth token found in localStorage. Keys: ${Object.keys(storage).join(', ')}`) + } + // The stored value is the JSON-serialized session. Handle both the flat + // shape ({ access_token, ... }) and the nested shape ({ session: { ... } }) + // across supabase-js versions. + const parsed = JSON.parse(storage[tokenKey]) as Record + const session = (parsed.session as Record | undefined) ?? parsed + const accessToken = session?.access_token + if (typeof accessToken !== 'string') { + throw new Error(`Supabase session payload has no string access_token: ${storage[tokenKey]}`) + } + return accessToken + } + + /** + * Queries PostgREST as the given user for `encrypted_fields` rows belonging + * to `entryId`. RLS scopes the result to `user_id = auth.uid()`, so a + * non-owner receives `[]` (200 OK, filtered) rather than an error. + */ + async function fetchEncryptedFieldsAs(accessToken: string, entryId: string): Promise { + const url = process.env.VITE_SUPABASE_URL + const anonKey = process.env.VITE_SUPABASE_ANON_KEY + if (!url || !anonKey) { + throw new Error('VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY must be set in .env.local') + } + const res = await fetch(`${url}/rest/v1/encrypted_fields?entry_id=eq.${entryId}&select=id`, { + headers: { apikey: anonKey, Authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) { + throw new Error(`PostgREST request failed: ${res.status} ${await res.text()}`) + } + return (await res.json()) as unknown[] + } + + test("RLS blocks a second user from reading another user's encrypted fields", async ({ page }) => { + // --- User A: register, create an entry, and persist a known title. --- + const usernameA = uniqueUsername('secA') + await registerUser(page, usernameA, PASSWORD) + const entryId = await createEntry(page) + await page.getByTestId('field-input-title').fill(TITLE_PLAINTEXT) + await expect(page.getByTestId('field-card-title').getByTestId('save-indicator')).toHaveText('Saved') + + // Log out so the same browser session can register and authenticate as B. + await page.getByTestId('logout-button').click() + await expect(page).toHaveURL(/\/login$/) + + // --- User B: register on the same page. localStorage now holds B's session. --- + const usernameB = uniqueUsername('secB') + await registerUser(page, usernameB, PASSWORD) + + // --- B attempts to read A's encrypted_fields by entry_id via PostgREST. --- + const accessToken = await extractAccessToken(page) + const rows = await fetchEncryptedFieldsAs(accessToken, entryId) + + // RLS restricts SELECT to `user_id = auth.uid()`; B ≠ A, so the row set is + // empty even though the row exists (confirmed by the test below). + expect(rows).toEqual([]) + }) + + test('stored ciphertext and auth hash never contain the plaintext password', async ({ page }) => { + const usernameA = uniqueUsername('leak') + await registerUser(page, usernameA, PASSWORD) + const entryId = await createEntry(page) + await page.getByTestId('field-input-title').fill(TITLE_PLAINTEXT) + await expect(page.getByTestId('field-card-title').getByTestId('save-indicator')).toHaveText('Saved') + + // --- Ciphertext must not leak the plaintext substring. --- + // Service-role connection bypasses RLS, so this reads A's actual rows. + const fieldRows = await queryRaw<{ ciphertext: string }>( + 'SELECT ciphertext FROM public.encrypted_fields WHERE entry_id = $1', + [entryId], + ) + expect(fieldRows.length).toBeGreaterThan(0) + for (const row of fieldRows) { + // AES-256-GCM ciphertext is base64; a substring match would mean the + // plaintext was stored verbatim. Covers every field of the entry. + expect(row.ciphertext).not.toContain(TITLE_PLAINTEXT) + } + + // --- auth.users.encrypted_password must be bcrypt-hashed, not raw. --- + // Username maps to `{username}@ciphernote.internal` for Supabase Auth. + const authRows = await queryRaw<{ encrypted_password: string }>( + 'SELECT encrypted_password FROM auth.users WHERE email = $1', + [`${usernameA}@ciphernote.internal`], + ) + expect(authRows).toHaveLength(1) + const hashed = authRows[0].encrypted_password + + // The plaintext password must never be stored verbatim. + expect(hashed).not.toBe(PASSWORD) + // Supabase bcrypt-hashes the supplied authHash; the stored value must look + // like a bcrypt hash, not the raw 64-hex-char authHash the client sends. + expect(hashed).toMatch(/^\$2[abxy]\$\d{2}\$/) + expect(hashed).not.toMatch(/^[0-9a-f]{64}$/) + }) +}) diff --git a/src/features/auth/ui/RegisterPage.test.tsx b/src/features/auth/ui/RegisterPage.test.tsx index 3ad6ab2..8347879 100644 --- a/src/features/auth/ui/RegisterPage.test.tsx +++ b/src/features/auth/ui/RegisterPage.test.tsx @@ -12,10 +12,10 @@ vi.mock('@/shared/lib/use-debounced-value', () => ({ useDebouncedValue: (value: unknown) => value, })) +const mockRpc = vi.fn().mockResolvedValue({ data: true, error: null }) + vi.mock('@/shared/api/supabase-client', () => ({ - getSupabase: () => ({ - rpc: vi.fn().mockResolvedValue({ data: true, error: null }), - }), + getSupabase: () => ({ rpc: mockRpc }), })) vi.mock('@tanstack/react-router', () => ({ @@ -31,6 +31,9 @@ const MNEMONIC = 'abandon ability able about above absent absorb abstract absurd describe('RegisterPage', () => { beforeEach(() => { vi.clearAllMocks() + // clearAllMocks clears call history but not implementations; restore the + // default "available" response so a per-test override can't leak forward. + mockRpc.mockResolvedValue({ data: true, error: null }) }) it('renders registration form fields', () => { @@ -142,4 +145,22 @@ describe('RegisterPage', () => { render() expect(screen.getByText(/log in/i)).toBeInTheDocument() }) + + it('disables submit and shows the taken badge when the username is already taken', async () => { + // check_username_availability returns false → useUsernameAvailability + // status flips to 'taken' → the "Username is already taken" badge renders + // and isSubmitDisabled (availability === 'taken') disables the submit + // button, blocking the registration attempt before it reaches onSubmit. + mockRpc.mockResolvedValue({ data: false, error: null }) + const user = userEvent.setup() + + render() + + await user.type(screen.getByLabelText(/username/i), 'testuser') + + await waitFor(() => { + expect(screen.getByText(/username is already taken/i)).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: /create account/i })).toBeDisabled() + }) }) From 0cafcbc8b0c652f4632edc5f4bf1eaf320fe5116 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 16:55:50 +0200 Subject: [PATCH 5/8] fix: remove circular CSS variable references in Sonner toast theming and prevent spinner flash on refetch --- src/features/fields/ui/DashboardPage.tsx | 2 +- src/shared/ui/sonner.tsx | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 5894eb7..30bb5aa 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -64,7 +64,7 @@ function FieldEditorWrapper({ ) } - if (fieldQuery.isFetching) { + if (fieldQuery.isFetching && !fieldQuery.data) { return (
diff --git a/src/shared/ui/sonner.tsx b/src/shared/ui/sonner.tsx index b9f30f4..396c42b 100644 --- a/src/shared/ui/sonner.tsx +++ b/src/shared/ui/sonner.tsx @@ -22,19 +22,17 @@ const Toaster = ({ ...props }: ToasterProps) => { }} style={ { + // Background/border colors for error/success/warning toasts are inherited + // from globals.css (--error-bg, --success-bg, --warning-bg, etc.) + // because Sonner uses the same CSS variable names as our theme. + // To change toast colors, edit the corresponding variables in globals.css. '--normal-bg': 'var(--popover)', '--normal-text': 'var(--popover-foreground)', '--normal-border': 'var(--border)', '--border-radius': 'var(--radius)', - '--error-bg': 'var(--error-bg)', '--error-text': 'var(--destructive)', - '--error-border': 'var(--error-border)', - '--success-bg': 'var(--success-bg)', '--success-text': 'var(--success)', - '--success-border': 'var(--success-border)', - '--warning-bg': 'var(--warning-bg)', '--warning-text': 'var(--warning)', - '--warning-border': 'var(--warning-border)', } as React.CSSProperties } toastOptions={{ From 738d529b5c0e1e9fa2e7af390dc4d1955f303120 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 16:57:04 +0200 Subject: [PATCH 6/8] refactor: extract shared E2E entry helpers to reduce duplication --- e2e/crypto.spec.ts | 15 +-------------- e2e/fields.spec.ts | 30 +----------------------------- e2e/helpers/entries.ts | 41 +++++++++++++++++++++++++++++++++++++++++ e2e/realtime.spec.ts | 8 +------- e2e/security.spec.ts | 15 +-------------- 5 files changed, 45 insertions(+), 64 deletions(-) create mode 100644 e2e/helpers/entries.ts diff --git a/e2e/crypto.spec.ts b/e2e/crypto.spec.ts index 948e781..83bc36d 100644 --- a/e2e/crypto.spec.ts +++ b/e2e/crypto.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from '@playwright/test' +import { createEntry } from './helpers/entries' import { resetUserData } from './helpers/db' import { login, registerUser, uniqueUsername } from './helpers/users' @@ -46,20 +47,6 @@ async function fillMnemonicInputs(page: Page, mnemonic: string): Promise { } } -/** - * Drives the sidebar "New note" button to create an entry and returns the - * entryId from the URL so the rotation test can re-navigate after rotating. - */ -async function createEntry(page: Page): Promise { - // `create-entry` renders on both the desktop sidebar and the md:hidden - // mobile nav; .first() targets the sidebar variant. - await page.getByTestId('create-entry').first().click() - await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) - const match = page.url().match(/\/dashboard\/([^/]+)$/) - if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) - return match[1] -} - test.describe('crypto', () => { test.beforeEach(async () => { await resetUserData() diff --git a/e2e/fields.spec.ts b/e2e/fields.spec.ts index 36ff5e0..214dca0 100644 --- a/e2e/fields.spec.ts +++ b/e2e/fields.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from '@playwright/test' +import { createEntry, entryIdFromUrl } from './helpers/entries' import { resetUserData } from './helpers/db' import { login, registerUser, uniqueUsername } from './helpers/users' @@ -33,35 +34,6 @@ test.describe('fields', () => { await resetUserData() }) - /** Captures the entryId from the current /dashboard/$entryId URL. */ - function entryIdFromUrl(page: Page): string { - const match = page.url().match(/\/dashboard\/([^/]+)$/) - if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) - return match[1] - } - - /** - * Drives the sidebar "New note" button to create an entry and returns the - * entryId from the URL so later reload/login steps can re-navigate to it. - */ - async function createEntry(page: Page): Promise { - // .first() targets the desktop sidebar variant (mobile nav is hidden). - const urlBefore = page.url() - await page.getByTestId('create-entry').first().click() - // If already viewing an entry, toHaveURL(regex) would resolve instantly and - // capture the wrong id — poll until the URL actually changes. - await expect - .poll( - () => { - const url = page.url() - return /\/dashboard\/[^/]+$/.test(url) && url !== urlBefore ? url : null - }, - { timeout: 10000 }, - ) - .not.toBeNull() - return entryIdFromUrl(page) - } - /** * Fills all four field inputs and asserts each SaveIndicator reaches "Saved". * Auto-save debounces 1s after the last keystroke, then transitions diff --git a/e2e/helpers/entries.ts b/e2e/helpers/entries.ts new file mode 100644 index 0000000..05d3bd3 --- /dev/null +++ b/e2e/helpers/entries.ts @@ -0,0 +1,41 @@ +import { expect, type Page } from '@playwright/test' + +/** + * Navigation helpers for E2E specs — entry creation and URL parsing. + * + * `createEntry` uses `expect.poll` to guard against the race where the user + * is already on `/dashboard/$entryId` and a click to create a new entry must + * wait for the URL to actually change before reading the new entry id. + */ + +/** Captures the entryId from the current /dashboard/$entryId URL. */ +export function entryIdFromUrl(page: Page): string { + const match = page.url().match(/\/dashboard\/([^/]+)$/) + if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) + return match[1] +} + +/** + * Drives the sidebar "New note" button to create an entry and returns the + * entryId from the URL so later reload/login steps can re-navigate to it. + * + * Uses `expect.poll` to wait until the URL actually changes — if the user is + * already viewing an entry, `toHaveURL(regex)` would resolve instantly and + * capture the wrong id. + */ +export async function createEntry(page: Page): Promise { + // `create-entry` renders on both the desktop sidebar and the md:hidden + // mobile nav; .first() targets the sidebar variant. + const urlBefore = page.url() + await page.getByTestId('create-entry').first().click() + await expect + .poll( + () => { + const url = page.url() + return /\/dashboard\/[^/]+$/.test(url) && url !== urlBefore ? url : null + }, + { timeout: 10_000 }, + ) + .not.toBeNull() + return entryIdFromUrl(page) +} \ No newline at end of file diff --git a/e2e/realtime.spec.ts b/e2e/realtime.spec.ts index 7a375c3..529efb4 100644 --- a/e2e/realtime.spec.ts +++ b/e2e/realtime.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Browser, type Page } from '@playwright/test' +import { entryIdFromUrl } from './helpers/entries' import { resetUserData } from './helpers/db' import { login, registerUser, uniqueUsername } from './helpers/users' @@ -49,13 +50,6 @@ async function openSecondSession( return { pageB, close: () => contextB.close() } } -/** Captures the entryId from the current /dashboard/$entryId URL. */ -function entryIdFromUrl(page: Page): string { - const match = page.url().match(/\/dashboard\/([^/]+)$/) - if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) - return match[1] -} - test.describe('realtime', () => { test.beforeEach(async () => { await resetUserData() diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index 95a08bc..08fcbc2 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from '@playwright/test' +import { createEntry } from './helpers/entries' import { queryRaw, resetUserData } from './helpers/db' import { registerUser, uniqueUsername } from './helpers/users' @@ -35,20 +36,6 @@ test.describe('security', () => { await resetUserData() }) - /** - * Drives the sidebar "New note" button to create an entry and returns the - * entryId from the URL so the RLS query and DB assertions can target A's row. - */ - async function createEntry(page: Page): Promise { - // `create-entry` renders on both the desktop sidebar and the md:hidden - // mobile nav; .first() targets the sidebar variant. - await page.getByTestId('create-entry').first().click() - await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) - const match = page.url().match(/\/dashboard\/([^/]+)$/) - if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) - return match[1] - } - /** * Reads the current Supabase session's access token from the page's * localStorage. supabase-js v2 persists the session under a From 72e6dd1e2fed9595e276d69f5292d21f25d385ac Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 17:13:20 +0200 Subject: [PATCH 7/8] fix: encode URL params in E2E security spec, add global teardown to close DB pool --- CLAUDE.md | 1 + docs/implementation-plan/09-phase-9-polish.md | 6 ++++-- docs/implementation-plan/README.md | 2 +- e2e/global-teardown.ts | 13 +++++++++++++ e2e/helpers/db.ts | 2 +- e2e/helpers/entries.ts | 2 +- e2e/security.spec.ts | 2 +- playwright.config.ts | 1 + 8 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 e2e/global-teardown.ts 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/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/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..d9d4767 --- /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 \ No newline at end of file diff --git a/e2e/helpers/db.ts b/e2e/helpers/db.ts index 7b4d0ac..887b913 100644 --- a/e2e/helpers/db.ts +++ b/e2e/helpers/db.ts @@ -51,7 +51,7 @@ export async function resetUserData(): Promise { * for a plaintext leak, or confirming `auth.users.encrypted_password` is * neither the raw password nor the raw `authHash`. */ -export async function queryRaw( +export async function queryRaw( sql: string, params?: unknown[], ): Promise['rows']> { diff --git a/e2e/helpers/entries.ts b/e2e/helpers/entries.ts index 05d3bd3..d38a4c4 100644 --- a/e2e/helpers/entries.ts +++ b/e2e/helpers/entries.ts @@ -38,4 +38,4 @@ export async function createEntry(page: Page): Promise { ) .not.toBeNull() return entryIdFromUrl(page) -} \ No newline at end of file +} diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index 08fcbc2..3070276 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -73,7 +73,7 @@ test.describe('security', () => { if (!url || !anonKey) { throw new Error('VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY must be set in .env.local') } - const res = await fetch(`${url}/rest/v1/encrypted_fields?entry_id=eq.${entryId}&select=id`, { + const res = await fetch(`${url}/rest/v1/encrypted_fields?entry_id=eq.${encodeURIComponent(entryId)}&select=id`, { headers: { apikey: anonKey, Authorization: `Bearer ${accessToken}` }, }) if (!res.ok) { diff --git a/playwright.config.ts b/playwright.config.ts index f2a8d3b..af53f33 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ expect: { timeout: 10_000 }, reporter: [['list'], ['html', { open: 'never' }]], globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', use: { baseURL, From ab054ecb6ea7d5d4380e07ebe874848f93861719 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 18:45:48 +0200 Subject: [PATCH 8/8] refactor: add mobile viewport E2E project and viewport-aware navigation helpers --- e2e/auth.spec.ts | 25 +++--- e2e/crypto.spec.ts | 38 ++++---- e2e/fields.spec.ts | 65 ++++++++------ e2e/global-teardown.ts | 2 +- e2e/helpers/entries.ts | 41 --------- e2e/helpers/navigation.ts | 180 ++++++++++++++++++++++++++++++++++++++ e2e/realtime.spec.ts | 42 ++++----- e2e/security.spec.ts | 12 +-- playwright.config.ts | 4 + 9 files changed, 286 insertions(+), 123 deletions(-) delete mode 100644 e2e/helpers/entries.ts create mode 100644 e2e/helpers/navigation.ts diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 5fa47eb..93e7ef7 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test' import { resetUserData } from './helpers/db' +import { clickSidebarButton, clickSidebarButtonByName } from './helpers/navigation' import { login, registerUser, uniqueUsername } from './helpers/users' /** @@ -33,13 +34,13 @@ test.describe('auth', () => { await expect(page.getByText(`Welcome ${username}`)).toBeVisible() }) - test('login with the correct password reaches the unlocked dashboard', async ({ page }) => { + 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 page.getByTestId('logout-button').click() + await clickSidebarButton(page, testInfo, 'logout-button') await expect(page).toHaveURL(/\/login$/) await login(page, username, PASSWORD) @@ -49,19 +50,19 @@ test.describe('auth', () => { await expect(page.getByText(`Welcome ${username}`)).toBeVisible() }) - test('unlocking the vault with the correct password restores access', async ({ page }) => { + 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 page.getByTestId('logout-button').click() + 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 page.getByRole('button', { name: 'Lock vault', exact: true }).click() + 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() @@ -80,19 +81,19 @@ test.describe('auth', () => { 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 }) => { + 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 page.getByTestId('logout-button').click() + 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 page.getByRole('button', { name: 'Lock vault', exact: true }).click() + await clickSidebarButtonByName(page, testInfo, 'Lock vault') // Scope to
— the sidebar VaultLockButton exposes an identically // named button that would trip Playwright strict mode. @@ -109,24 +110,24 @@ test.describe('auth', () => { await expect(dialog).toBeVisible() }) - test('logout from the sidebar redirects to login', async ({ page }) => { + test('logout from the sidebar redirects to login', async ({ page }, testInfo) => { const username = uniqueUsername('logout') await registerUser(page, username, PASSWORD) - await page.getByTestId('logout-button').click() + 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 }) => { + 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 page.getByTestId('logout-button').click() + await clickSidebarButton(page, testInfo, 'logout-button') await expect(page).toHaveURL(/\/login$/) await page.goto('/register') diff --git a/e2e/crypto.spec.ts b/e2e/crypto.spec.ts index 83bc36d..fbfb7f4 100644 --- a/e2e/crypto.spec.ts +++ b/e2e/crypto.spec.ts @@ -1,7 +1,13 @@ import { expect, test, type Page } from '@playwright/test' -import { createEntry } from './helpers/entries' import { resetUserData } from './helpers/db' +import { + clickEntryNav, + clickFirstEntry, + clickSidebarButton, + createEntry, + navigateToSettings, +} from './helpers/navigation' import { login, registerUser, uniqueUsername } from './helpers/users' /** @@ -52,7 +58,7 @@ test.describe('crypto', () => { await resetUserData() }) - test('change password: old password fails, new password unlocks the vault', async ({ page }) => { + test('change password: old password fails, new password unlocks the vault', async ({ page }, testInfo) => { const username = uniqueUsername('changepw') await registerUser(page, username, PASSWORD) @@ -60,7 +66,7 @@ test.describe('crypto', () => { // (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 page.getByTestId('nav-settings').first().click() + await navigateToSettings(page, testInfo) await page.waitForURL('**/settings') await page.getByTestId('settings-change-password').click() const dialog = page.getByRole('dialog') @@ -74,7 +80,7 @@ test.describe('crypto', () => { await expect(dialog).not.toBeVisible() // Logout clears the local session + key state. - await page.getByTestId('logout-button').click() + await clickSidebarButton(page, testInfo, 'logout-button') await expect(page).toHaveURL(/\/login$/) // The old authHash no longer matches Supabase Auth, so login rejects and @@ -92,12 +98,12 @@ test.describe('crypto', () => { await expect(page.getByText(`Welcome ${username}`)).toBeVisible() }) - test('change password: wrong current password shows an error and leaves the session intact', async ({ page }) => { + 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 page.getByTestId('nav-settings').first().click() + await navigateToSettings(page, testInfo) await page.waitForURL('**/settings') await page.getByTestId('settings-change-password').click() const dialog = page.getByRole('dialog') @@ -122,19 +128,19 @@ test.describe('crypto', () => { // field-input-note visible proves the keys survived. await page.keyboard.press('Escape') await expect(dialog).not.toBeVisible() - await page.getByTestId('entry-nav-item').first().click() + 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 }) => { + 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 page.getByTestId('nav-settings').first().click() + await navigateToSettings(page, testInfo) await page.waitForURL('**/settings') await page.getByTestId('settings-verify-mnemonic').click() const verifyDialog = page.getByRole('dialog') @@ -166,14 +172,14 @@ test.describe('crypto', () => { await expect(page.getByText('Seed phrase regenerated successfully', { exact: true })).toBeVisible() }) - test('account recovery: mnemonic + new password restores access', async ({ page }) => { + 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 page.getByTestId('logout-button').click() + await clickSidebarButton(page, testInfo, 'logout-button') await expect(page).toHaveURL(/\/login$/) await page.getByRole('link', { name: 'Forgot password?' }).click() @@ -199,7 +205,7 @@ test.describe('crypto', () => { // login to prove it unlocks the vault. await expect(page).toHaveURL(/\/(dashboard|login)$/) if (page.url().endsWith('/dashboard')) { - await page.getByTestId('logout-button').click() + await clickSidebarButton(page, testInfo, 'logout-button') await expect(page).toHaveURL(/\/login$/) } @@ -210,12 +216,12 @@ test.describe('crypto', () => { await expect(page.getByText(`Welcome ${username}`)).toBeVisible() }) - test('rotating a single field key increments the version and preserved content still decrypts', async ({ page }) => { + 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) + 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') @@ -223,7 +229,7 @@ test.describe('crypto', () => { // 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 page.getByTestId('nav-settings').first().click() + 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]') @@ -247,7 +253,7 @@ test.describe('crypto', () => { // 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 page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first().click() + 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 index 214dca0..2588292 100644 --- a/e2e/fields.spec.ts +++ b/e2e/fields.spec.ts @@ -1,7 +1,15 @@ import { expect, test, type Page } from '@playwright/test' -import { createEntry, entryIdFromUrl } from './helpers/entries' import { resetUserData } from './helpers/db' +import { + clickEntryNav, + clickFirstEntry, + clickSidebarButton, + createEntry, + entryIdFromUrl, + entryNavLocator, + getSidebar, +} from './helpers/navigation' import { login, registerUser, uniqueUsername } from './helpers/users' /** @@ -74,11 +82,11 @@ test.describe('fields', () => { } } - test('typing into all four fields auto-saves and persists across reload + re-unlock', async ({ page }) => { + 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) + const entryId = await createEntry(page, testInfo) await fillAllFieldsAndAwaitSaved(page) // Reload drops the in-memory master key → vault locks → LockedVaultCard. @@ -90,35 +98,33 @@ test.describe('fields', () => { await expectFieldsRestored(page) }) - test('saved values re-decrypt after logout and a fresh login', async ({ 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) + 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 page.getByTestId('logout-button').click() + await clickSidebarButton(page, testInfo, 'logout-button') await expect(page).toHaveURL(/\/login$/) await login(page, username, PASSWORD) - await page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first().click() + 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 }) => { + 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, then delete it — with zero - // entries remaining, /dashboard renders EmptyState (not DashboardWelcome). - // `entry-nav-item` renders on both desktop sidebar and md:hidden mobile - // nav; .first() targets the sidebar variant. - await page.getByTestId('entry-nav-item').first().click() + // 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() @@ -135,12 +141,14 @@ test.describe('fields', () => { await expect(page.getByTestId('create-entry-empty')).toBeVisible() }) - test('multiple entries keep per-entry decrypted content when switching via the sidebar', async ({ page }) => { + 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 page.getByTestId('entry-nav-item').first().click() + await clickFirstEntry(page, testInfo) await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) const entry1Id = entryIdFromUrl(page) const entry1Values: FieldValues = { @@ -152,7 +160,7 @@ test.describe('fields', () => { await fillAllFieldsAndAwaitSaved(page, entry1Values) // Entry 2: create via the sidebar "New note" button, type different values. - const entry2Id = await createEntry(page) + const entry2Id = await createEntry(page, testInfo) const entry2Values: FieldValues = { title: 'Entry two title', website: 'https://two.example.com', @@ -163,30 +171,30 @@ test.describe('fields', () => { // 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. .first() scopes to the - // sidebar variant (entry-nav-item also renders in the mobile nav). - await page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entry1Id}"]`).first().click() + // 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 page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entry2Id}"]`).first().click() + 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 }) => { + test('editing the title field updates the sidebar nav item label', async ({ page }, testInfo) => { const username = uniqueUsername('title') await registerUser(page, username, PASSWORD) - // Open the auto-created entry; its sidebar item initially shows the i18n - // fallback "Note 1" because the title field is empty (EntryNavItem falls - // back to entryLabel when the decrypted title is empty). - await page.getByTestId('entry-nav-item').first().click() + // 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) - const navItem = page.locator(`[data-testid="entry-nav-item"][data-entry-id="${entryId}"]`).first() - await expect(navItem).toContainText('Note 1') // Type a title and let auto-save round-trip. useSaveField optimistically // writes the plaintext into the field.detail cache onMutate, so @@ -196,6 +204,9 @@ test.describe('fields', () => { 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-teardown.ts b/e2e/global-teardown.ts index d9d4767..ae79679 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -10,4 +10,4 @@ async function globalTeardown() { await closeDb() } -export default globalTeardown \ No newline at end of file +export default globalTeardown diff --git a/e2e/helpers/entries.ts b/e2e/helpers/entries.ts deleted file mode 100644 index d38a4c4..0000000 --- a/e2e/helpers/entries.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, type Page } from '@playwright/test' - -/** - * Navigation helpers for E2E specs — entry creation and URL parsing. - * - * `createEntry` uses `expect.poll` to guard against the race where the user - * is already on `/dashboard/$entryId` and a click to create a new entry must - * wait for the URL to actually change before reading the new entry id. - */ - -/** Captures the entryId from the current /dashboard/$entryId URL. */ -export function entryIdFromUrl(page: Page): string { - const match = page.url().match(/\/dashboard\/([^/]+)$/) - if (!match) throw new Error(`expected entry id in URL, got ${page.url()}`) - return match[1] -} - -/** - * Drives the sidebar "New note" button to create an entry and returns the - * entryId from the URL so later reload/login steps can re-navigate to it. - * - * Uses `expect.poll` to wait until the URL actually changes — if the user is - * already viewing an entry, `toHaveURL(regex)` would resolve instantly and - * capture the wrong id. - */ -export async function createEntry(page: Page): Promise { - // `create-entry` renders on both the desktop sidebar and the md:hidden - // mobile nav; .first() targets the sidebar variant. - const urlBefore = page.url() - await page.getByTestId('create-entry').first().click() - await expect - .poll( - () => { - const url = page.url() - return /\/dashboard\/[^/]+$/.test(url) && url !== urlBefore ? url : null - }, - { timeout: 10_000 }, - ) - .not.toBeNull() - return entryIdFromUrl(page) -} 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 `