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>
65 lines
2.3 KiB
TypeScript
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 });
|
|
});
|
|
});
|