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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
58 changes: 12 additions & 46 deletions packages/web/e2e/create-read.spec.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,33 @@
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,
}, testInfo) => {
// 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();

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);
});
});
66 changes: 66 additions & 0 deletions packages/web/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string> {
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;
}
28 changes: 28 additions & 0 deletions packages/web/e2e/password.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 13 additions & 7 deletions packages/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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',
Expand All @@ -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: {
Expand Down
Loading