Files
collective-lists/apps/web/tests/e2e/insecure-context.test.ts
Oier Bravo Urtasun 53de447aa5 fix: generateId() fallback for insecure-context origins
Problem
  The phone-preview path (http://192.168.1.167:5173) is a non-secure
  origin. `window.crypto.randomUUID` is gated behind secure contexts
  (HTTPS / localhost / 127.0.0.1 / file://) so it's undefined there,
  and every optimistic-id call site crashed handleAdd with
  `TypeError: crypto.randomUUID is not a function`.

Fix
  apps/web/src/lib/utils/id.ts — generateId() uses crypto.randomUUID()
    when available, otherwise falls back to crypto.getRandomValues()
    + RFC 4122 §4.4 byte assembly. getRandomValues IS exposed on
    insecure contexts (unlike subtle), so the fallback is v4-compliant
    and cryptographically acceptable for client-side optimistic ids.
  Replaced crypto.randomUUID() at every call site:
    stores/lists.ts, stores/tasks.ts, sync/queue.ts, sync/undoQueue.ts
    routes/(app)/lists/[id]/+page.svelte
    routes/(app)/tasks/[id]/+page.svelte
  CLAUDE.md gotcha added: never call crypto.randomUUID() directly —
    always import generateId from $lib/utils/id.

Tests (written first, confirmed failing on main)
  src/lib/utils/id.test.ts (4 tests)
    U-01 returns UUID v4 when randomUUID is available
    U-02 falls back when randomUUID is undefined
    U-03 50 fallback-generated ids are all unique
    U-04 prefers randomUUID when present (doesn't drop into fallback)
  tests/e2e/insecure-context.test.ts (1 test)
    INS-01 stubs crypto.randomUUID = undefined via addInitScript,
      drives the add-item flow, confirms the row renders (if the
      old call site were still there, handleAdd would throw).

Verification
  just test-all → exit 0, 233 green, 2 skipped:
    34 pgTAP (unchanged)
    140 Vitest integration + 2 skipped presence (unchanged)
    15 Vitest unit (was 11, +4 generateId)
    44 Playwright (was 43, +1 INS-01)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 02:41:39 +02:00

65 lines
2.3 KiB
TypeScript

/**
* INS-series — insecure-context regression
*
* When the app is served over a non-secure origin (http://<LAN-IP>, used for
* on-device phone previews), `window.crypto.randomUUID` is undefined and every
* optimistic-id generator that called it crashed `handleAdd`.
*
* We reproduce that condition by shimming `crypto.randomUUID` to `undefined`
* via `addInitScript` on a page that otherwise runs on localhost (where
* Playwright has a deterministic OAuth path). Same code path, just with the
* API removed.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_LIST = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
test.describe('Insecure-context resilience', () => {
test('INS-01: adding an item works when crypto.randomUUID is undefined', async ({ context, page }) => {
await context.addInitScript(() => {
// Simulate an insecure origin: randomUUID is gated behind secure
// contexts and therefore missing. getRandomValues stays available.
Object.defineProperty(window.crypto, 'randomUUID', {
configurable: true,
value: undefined
});
});
// Re-assert the stub after navigation in case the context re-injects.
page.on('framenavigated', async () => {
await page
.evaluate(() => {
Object.defineProperty(window.crypto, 'randomUUID', {
configurable: true,
value: undefined
});
})
.catch(() => {});
});
await loginAs(page, USERS.borja);
await page.goto(SEED_LIST);
// Sanity: the stub stuck — the page sees randomUUID as undefined.
const stubbed = await page.evaluate(() => typeof window.crypto.randomUUID);
expect(stubbed).toBe('undefined');
const input = page.getByPlaceholder(/add item|añadir producto/i);
await expect(input).toBeVisible({ timeout: 10_000 });
const itemName = `INS-01-${Date.now()}`;
await input.fill(itemName);
await input.press('Enter');
// The row must render — i.e. generateId() returned a valid UUID and the
// optimistic insert + server round-trip completed. If the old
// crypto.randomUUID() call site is still there, handleAdd throws a
// TypeError and the row never appears.
await expect(
page.locator('[role="listitem"]').filter({ hasText: itemName }).first()
).toBeVisible({ timeout: 5_000 });
});
});