diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea7b0e6..cc75a92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,9 +70,9 @@ jobs: - name: Install Playwright Chromium run: pnpm --filter @crypt.fyi/web exec playwright install chromium --with-deps - - name: E2E smoke (create + read) + - name: E2E (create/read/burn + password under production CSP) run: pnpm test:e2e env: CI: true - VITE_API_URL: http://localhost:4321 + VITE_API_URL: http://localhost:4322 REDIS_URL: redis://127.0.0.1:6379 diff --git a/AGENTS.md b/AGENTS.md index 0807a47..4e33ec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ pnpm dev # Redis + turbo dev (API default: http://localhost:4321) pnpm lint pnpm typecheck pnpm test -pnpm test:e2e # Playwright create→read smoke under production CSP +pnpm test:e2e # Playwright product + CSP smoke (create/read/burn, password) pnpm format / pnpm format:check pnpm changeset # version bumps for publishable packages ``` diff --git a/README.md b/README.md index 3783fb4..b7b3d61 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,9 @@ Publishable packages: `@crypt.fyi/core` and `@crypt.fyi/cli` (npm), plus the Chr - Production CSP (including `style-src` hashes for sonner/Radix inline styles) lives in `nginx/nginx.conf` - `vite preview` applies that same policy via `packages/web/csp.ts` so local/CI match production -- `pnpm test:e2e` runs a Playwright create→read smoke under that CSP. It fails on `style-src` / `connect-src` violations; other console warnings/errors and non-style CSP noise (e.g. `script-src` eval fallback notes) are reported as non-blocking annotations -- When the smoke fails on style-src, add the reported hash to `nginx/nginx.conf` (do not weaken to `'unsafe-inline'`) +- `pnpm test:e2e` runs Playwright against `vite preview` under that CSP (default create→read→burn, password unlock). It fails on `style-src` / `connect-src` violations; other console warnings/errors and non-style CSP noise (e.g. `script-src` eval fallback notes) are reported as non-blocking annotations +- The e2e API uses an in-memory rate limiter and dedicated ports (`:4322` API / `:4173` preview by default) so local `pnpm dev` and shared Redis rate-limit keys cannot poison the suite. Playwright rebuilds the web client with that API URL before preview so CSP `connect-src` matches the baked client config. +- When a test fails on style-src, add the reported hash to `nginx/nginx.conf` (do not weaken to `'unsafe-inline'`) - Reference: [sonner#449](https://github.com/emilkowalski/sonner/issues/449) ### Development Environment diff --git a/packages/server/src/app.test.ts b/packages/server/src/app.test.ts index b80d3f0..d29e4a4 100644 --- a/packages/server/src/app.test.ts +++ b/packages/server/src/app.test.ts @@ -16,6 +16,8 @@ const initAppTest = async () => { ...baseConfig, healthCheckEndpoint: '/some-health-check-endpoint', vaultEntryTTLMsDefault: 1000, + // Keep counters out of Redis so unit tests cannot poison e2e / local API limits. + rateLimiter: 'memory', rateLimitMax: Number.MAX_SAFE_INTEGER, } satisfies Config; const logger = pino({ enabled: false }); diff --git a/packages/web/e2e/create-read.spec.ts b/packages/web/e2e/create-read.spec.ts index 2ad2a37..c3fceac 100644 --- a/packages/web/e2e/create-read.spec.ts +++ b/packages/web/e2e/create-read.spec.ts @@ -1,13 +1,8 @@ import { test, expect } from './fixtures'; - -const SECRET = `playwright-smoke-${Date.now()}`; - -/** Violations that break the product or the reason we added this smoke. */ -const isBlockingCspViolation = (directive: string) => - directive === 'connect-src' || directive.startsWith('style-src'); +import { assertNoBlockingCsp, createTextSecret } from './helpers'; test.describe('create and read smoke', () => { - test('creates a secret and reads it back under production CSP', async ({ + test('creates, reads, and burns under production CSP', async ({ page, consoleEntries, cspViolations, @@ -15,17 +10,9 @@ test.describe('create and read smoke', () => { // Ensure console fixture is active for the whole test (reporting is non-blocking). void consoleEntries; - await page.goto('/new'); - - await page.getByLabel('Secret content').fill(SECRET); - await page.getByRole('button', { name: 'Create' }).click(); - - await expect(page.getByRole('heading', { name: 'Secret Created!' })).toBeVisible(); - - // Unmask before reading the value (success fields are masked by default). - await page.getByRole('button', { name: 'Show secret URL' }).first().click(); - const combinedUrl = await page.locator('#combined-url').inputValue(); - expect(combinedUrl).toMatch(/\/[^/#?\s]+#/); + const secret = `playwright-smoke-${Date.now()}`; + // Default create path enables burn-after-reading. + const combinedUrl = await createTextSecret(page, secret); // Exercise toast styling paths that often trip style-src. await page.getByRole('button', { name: 'Copy secret URL' }).first().click(); @@ -33,35 +20,14 @@ test.describe('create and read smoke', () => { await page.goto(combinedUrl); await page.getByRole('button', { name: 'View Secret' }).click(); - await expect(page.getByLabel('Secret content')).toHaveText(SECRET); - - const blocking = cspViolations.filter((v) => isBlockingCspViolation(v.effectiveDirective)); - const reported = cspViolations.filter((v) => !isBlockingCspViolation(v.effectiveDirective)); + await expect(page.getByLabel('Secret content')).toHaveText(secret); + await expect(page.getByText(/permanently deleted/i)).toBeVisible(); - if (reported.length > 0) { - const summary = reported - .map( - (v) => - `[${v.effectiveDirective}] blocked ${v.blockedURI || '(inline)'} @ ${v.sourceFile}:${v.lineNumber}`, - ) - .join('\n'); - testInfo.annotations.push({ - type: 'csp', - description: `${reported.length} non-blocking CSP violation(s):\n${summary}`, - }); - console.warn(`\n[smoke] non-blocking CSP noise (${reported.length}):\n${summary}\n`); - } + // Same-URL goto can no-op in Chromium; leave and return to remount View. + await page.goto('/new'); + await page.goto(combinedUrl); + await expect(page.getByRole('heading', { name: 'Secret Not Found' })).toBeVisible(); - expect( - blocking, - blocking.length - ? `Blocking CSP violation(s) under production policy:\n${blocking - .map( - (v) => - `- ${v.effectiveDirective} blocked ${v.blockedURI || '(inline)'} @ ${v.sourceFile}:${v.lineNumber}`, - ) - .join('\n')}\nUpdate nginx/nginx.conf (or fix the offender), then re-run.` - : '', - ).toEqual([]); + await assertNoBlockingCsp(cspViolations, testInfo); }); }); diff --git a/packages/web/e2e/helpers.ts b/packages/web/e2e/helpers.ts new file mode 100644 index 0000000..6b2e44a --- /dev/null +++ b/packages/web/e2e/helpers.ts @@ -0,0 +1,66 @@ +import { expect, type Page, type TestInfo } from '@playwright/test'; +import type { CspViolation } from './fixtures'; + +/** Violations that break the product or the reason we run under production CSP. */ +export const isBlockingCspViolation = (directive: string) => + directive === 'connect-src' || directive.startsWith('style-src'); + +export async function assertNoBlockingCsp( + cspViolations: CspViolation[], + testInfo: TestInfo, +): Promise { + const blocking = cspViolations.filter((v) => isBlockingCspViolation(v.effectiveDirective)); + const reported = cspViolations.filter((v) => !isBlockingCspViolation(v.effectiveDirective)); + + if (reported.length > 0) { + const summary = reported + .map( + (v) => + `[${v.effectiveDirective}] blocked ${v.blockedURI || '(inline)'} @ ${v.sourceFile}:${v.lineNumber}`, + ) + .join('\n'); + testInfo.annotations.push({ + type: 'csp', + description: `${reported.length} non-blocking CSP violation(s):\n${summary}`, + }); + console.warn(`\n[e2e] non-blocking CSP noise (${reported.length}):\n${summary}\n`); + } + + expect( + blocking, + blocking.length + ? `Blocking CSP violation(s) under production policy:\n${blocking + .map( + (v) => + `- ${v.effectiveDirective} blocked ${v.blockedURI || '(inline)'} @ ${v.sourceFile}:${v.lineNumber}`, + ) + .join('\n')}\nUpdate nginx/nginx.conf (or fix the offender), then re-run.` + : '', + ).toEqual([]); +} + +/** Combined share URL: path, optional `?p=true`, then `#` decryption key. */ +export const combinedSecretUrlPattern = /\/[^/#?\s]+(?:\?[^#]*)?#/; + +export async function createTextSecret( + page: Page, + secret: string, + options: { password?: string } = {}, +): Promise { + await page.goto('/new'); + + await page.getByLabel('Secret content').fill(secret); + if (options.password) { + await page.getByLabel('Password', { exact: true }).fill(options.password); + } + + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('heading', { name: 'Secret Created!' })).toBeVisible(); + + // Success fields are masked by default. + await page.getByRole('button', { name: 'Show secret URL' }).first().click(); + const combinedUrl = await page.locator('#combined-url').inputValue(); + expect(combinedUrl).toMatch(combinedSecretUrlPattern); + + return combinedUrl; +} diff --git a/packages/web/e2e/password.spec.ts b/packages/web/e2e/password.spec.ts new file mode 100644 index 0000000..e9206b6 --- /dev/null +++ b/packages/web/e2e/password.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from './fixtures'; +import { assertNoBlockingCsp, createTextSecret } from './helpers'; + +test.describe('password-protected secret', () => { + test('creates with password and unlocks under production CSP', async ({ + page, + consoleEntries, + cspViolations, + }, testInfo) => { + void consoleEntries; + + const secret = `playwright-password-${Date.now()}`; + const password = `pw-${Date.now()}`; + const combinedUrl = await createTextSecret(page, secret, { password }); + + expect(combinedUrl).toContain('p=true'); + + await page.goto(combinedUrl); + await expect(page.getByRole('heading', { name: 'Enter Password' })).toBeVisible(); + + await page.locator('#secret-password').fill(password); + await page.getByRole('button', { name: 'Unlock secret' }).click(); + + await expect(page.getByLabel('Secret content')).toHaveText(secret); + + await assertNoBlockingCsp(cspViolations, testInfo); + }); +}); diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index fa47317..39f5b04 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -5,10 +5,12 @@ import { loadProductionCsp } from './csp'; const webRoot = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(webRoot, '../..'); -const apiUrl = process.env.VITE_API_URL ?? 'http://localhost:4321'; +// Dedicated ports so local `pnpm dev` (API :4321 / Vite :5173) can stay up without +// Playwright attaching to the wrong process or failing to bind. +const apiUrl = process.env.VITE_API_URL ?? 'http://localhost:4322'; const webPort = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 4173); const webOrigin = `http://localhost:${webPort}`; -const apiPort = new URL(apiUrl).port || '4321'; +const apiPort = new URL(apiUrl).port || '4322'; const previewCsp = loadProductionCsp(apiUrl); export default defineConfig({ @@ -31,7 +33,8 @@ export default defineConfig({ { command: `node "${path.join(repoRoot, 'packages/server/dist/index.js')}"`, url: `${apiUrl.replace(/\/$/, '')}/health`, - reuseExistingServer: !process.env.CI, + // Always spawn our own API so RATE_LIMITER / MAX / PORT cannot be silently ignored. + reuseExistingServer: false, timeout: 120_000, stdout: 'pipe', stderr: 'pipe', @@ -41,16 +44,19 @@ export default defineConfig({ REDIS_URL: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', CORS_ORIGIN: '*', PORT: apiPort, - // Smoke can re-run quickly; don't trip the default 10 req/min vault limit. + // Process-local counters: no shared Redis rate-limit keys with unit tests or `pnpm dev`. + RATE_LIMITER: process.env.RATE_LIMITER ?? 'memory', RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX ?? '1000', }, }, { - command: `pnpm exec vite preview --host localhost --port ${webPort}`, + // Rebuild so `import.meta.env.VITE_API_URL` matches the e2e API port/CSP + // (vite preview only serves already-baked assets). + command: `pnpm exec vite build && pnpm exec vite preview --host localhost --port ${webPort}`, cwd: webRoot, url: webOrigin, - reuseExistingServer: !process.env.CI, - timeout: 120_000, + reuseExistingServer: false, + timeout: 180_000, stdout: 'pipe', stderr: 'pipe', env: {