From 0df599ae53c75dd7e676968edf507012ebed527a Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 18:46:46 -0600 Subject: [PATCH 01/32] playwright e2e tests --- .github/workflows/ci.yml | 24 +- .github/workflows/release.yml | 6 + bun.lock | 11 +- frontend/components/ui/peer-list.tsx | 3 +- frontend/types/index.ts | 1 + llm/implementation/e2e-smoke-tests.md | 371 ++++++++++++++++++ .../node-lifecycle-implementation.md | 2 +- llm/implementation/umbrel-implementation.md | 6 +- package.json | 6 + playwright-report/index.html | 85 ++++ playwright.config.ts | 43 ++ scripts/release.sh | 6 +- src/class/relay.ts | 13 +- src/routes/env.ts | 14 + src/routes/utils.ts | 6 +- test-results/.last-run.json | 4 + tests/e2e/cosigner.mjs | 61 +++ tests/e2e/global-setup.ts | 284 ++++++++++++++ tests/e2e/global-teardown.ts | 48 +++ tests/e2e/specs/01-auth.e2e.ts | 117 ++++++ tests/e2e/specs/02-status-peers.e2e.ts | 98 +++++ tests/e2e/specs/03-nip44-nip04.e2e.ts | 140 +++++++ tests/e2e/specs/04-sign.e2e.ts | 130 ++++++ tests/e2e/specs/05-admin.e2e.ts | 138 +++++++ tests/e2e/specs/06-event-log.e2e.ts | 86 ++++ tests/e2e/specs/07-env.e2e.ts | 85 ++++ tests/e2e/specs/08-ui.e2e.ts | 124 ++++++ tests/e2e/state.ts | 51 +++ tests/routes/helpers/script-runner.ts | 56 ++- 29 files changed, 2003 insertions(+), 16 deletions(-) create mode 100644 llm/implementation/e2e-smoke-tests.md create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/cosigner.mjs create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/global-teardown.ts create mode 100644 tests/e2e/specs/01-auth.e2e.ts create mode 100644 tests/e2e/specs/02-status-peers.e2e.ts create mode 100644 tests/e2e/specs/03-nip44-nip04.e2e.ts create mode 100644 tests/e2e/specs/04-sign.e2e.ts create mode 100644 tests/e2e/specs/05-admin.e2e.ts create mode 100644 tests/e2e/specs/06-event-log.e2e.ts create mode 100644 tests/e2e/specs/07-env.e2e.ts create mode 100644 tests/e2e/specs/08-ui.e2e.ts create mode 100644 tests/e2e/state.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2303ea..1245aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Type check run: bun run tsc --noEmit + - name: Run backend tests + run: bun run test:unit + - name: Build frontend run: bun run build @@ -71,16 +74,33 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run security audit run: | - bun audit || true # Don't fail on audit issues for now + for attempt in 1 2 3; do + if bun audit; then + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + echo "bun audit failed (attempt $attempt), retrying..." + sleep 5 + fi + done + echo "bun audit failed after retries" + exit 1 - name: Check for secrets uses: trufflesecurity/trufflehog@main with: path: ./ extra_args: --debug --only-verified - continue-on-error: true docker: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc2ddea..1627643 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,12 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "version_number=${NEW_VERSION#v}" >> $GITHUB_OUTPUT + - name: Type check + run: bun run typecheck + + - name: Run backend tests + run: bun run test:unit + - name: Build application run: bun run build diff --git a/bun.lock b/bun.lock index 0dc4d79..079d0ee 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "yaml": "^2.8.1", }, "devDependencies": { + "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", @@ -184,6 +185,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -430,7 +433,7 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -618,6 +621,10 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], @@ -838,6 +845,8 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 1ccd38f..96adf38 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -611,10 +611,11 @@ const PeerList: React.FC = ({ } Peer List -
e.stopPropagation()}> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> } content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 0563d44..a7d1627 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -124,6 +124,7 @@ export interface UpdateInfo { updateAvailable: boolean; releaseUrl?: string; checkedAt?: string; + source?: 'github-release' | 'github-tags'; error?: string; } diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md new file mode 100644 index 0000000..5c40865 --- /dev/null +++ b/llm/implementation/e2e-smoke-tests.md @@ -0,0 +1,371 @@ +# E2E Smoke Test Suite (Playwright – DB Mode) + +Last verified: 2026-02-20 +Test count: 62 (54 API + 8 UI) — all passing + +## Purpose + +The Playwright smoke test suite exercises igloo-server end-to-end in **database mode** (the default, `HEADLESS=false`). It starts a real server process, spins up a live FROSTR co-signer, completes the full onboarding flow, and then runs two categories of tests: + +- **API project** (`01`–`07`): Pure HTTP request-context tests — no browser. Cover auth, status, peers, NIP-44, NIP-04, signing, admin, event log, and credential management. +- **UI project** (`08`): Headless Chrome browser tests. Cover the login page, tab navigation, and the Event Log section embedded in the Signer tab. + +## Running the Tests + +```bash +# Full suite (both projects) +npx playwright test + +# API-only (faster, no browser dependency) +npx playwright test --project=api + +# UI-only +npx playwright test --project=ui + +# Single spec file +npx playwright test tests/e2e/specs/04-sign.e2e.ts + +# HTML report (opens automatically after a run that had failures) +npx playwright show-report +``` + +Prerequisites: +- `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). +- `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). +- No other process listening on port 18002. + +## File Structure + +``` +tests/e2e/ +├── global-setup.ts # Starts server + co-signer, completes onboarding, writes state.json +├── global-teardown.ts # SIGTERMs both processes, deletes temp dir +├── state.ts # loadState() helper — reads JSON written by global-setup +├── cosigner.mjs # Minimal FROSTR co-signer subprocess (node/ESM) +└── specs/ + ├── 01-auth.e2e.ts + ├── 02-status-peers.e2e.ts + ├── 03-nip44-nip04.e2e.ts + ├── 04-sign.e2e.ts + ├── 05-admin.e2e.ts + ├── 06-event-log.e2e.ts + ├── 07-env.e2e.ts + └── 08-ui.e2e.ts + +playwright.config.ts # Project definitions: "api" (01–07), "ui" (08) +``` + +## Global Setup (`global-setup.ts`) + +The setup runs **once** before all tests and does the following in order: + +### 1. Generate a 2-of-2 FROSTR keyset + +```typescript +const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); +``` + +A **2-of-2** (not 2-of-3) scheme is used deliberately: with exactly two shares, the one connected co-signer is always sufficient to reach threshold without any ambiguity about which peer is needed. `TEST_NSEC_HEX` is a fixed 32-byte private key so the keyset is deterministic across runs. + +### 2. Start igloo-server + +The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, logFile)`. Key env overrides: + +| Variable | Value | Reason | +|---|---|---| +| `HOST_PORT` | `18002` | Fixed test port | +| `HOST_NAME` | `127.0.0.1` | Loopback only | +| `ADMIN_SECRET` | `SmokeTestAdmin1` | Deterministic | +| `DB_PATH` | `$TMPDIR/igloo-smoke-test/db` | Fresh DB per run | +| `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | +| `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | +| `ALLOW_LOCALHOST_RELAY` | `true` | Allow `ws://127.0.0.1:18002` as a relay URL | +| `FROSTR_SIGN_TIMEOUT` | `15000` | Allow 15 s for threshold signing | +| `GROUP_CRED` | `''` | Clear any `.env` credential interference | +| `SHARE_CRED` | `''` | Clear any `.env` credential interference | +| `RELAYS` | `''` | Clear any `.env` relay interference | +| `NODE_ENV` | `test` | Suppresses some production-only behaviors | + +**Critical**: Bun automatically loads `.env` from the current directory into `process.env`. If the developer's `.env` contains stale `GROUP_CRED`/`SHARE_CRED`/`RELAYS` values from a different port, the server would connect its bifrost node to the wrong relay, making signing always time out. The empty-string overrides above force those variables to be blank regardless of what `.env` contains. + +### 3. Complete onboarding + +``` +POST /api/onboarding/validate-admin (Bearer ADMIN_SECRET) +POST /api/onboarding/setup (creates admin user with username + password) +POST /api/auth/login → sessionId +``` + +### 4. Set FROSTR credentials + +``` +POST /api/user/credentials { group_cred, share_cred: shareCredentials[0], relays: ['ws://127.0.0.1:18002'] } +``` + +In DB mode, credentials are stored per-user encrypted in SQLite. The server then creates the in-memory bifrost node and polls `GET /api/status` until `nodeActive === true`. + +### 5. Start co-signer + +```bash +node tests/e2e/cosigner.mjs ws://127.0.0.1:18002 +``` + +`cosigner.mjs` creates a bifrost node using `@frostr/igloo-core` directly (no igloo-cli TUI). The server holds `shareCredentials[0]`; the co-signer holds `shareCredentials[1]`. Both connect to the server's built-in Nostr relay at `ws://127.0.0.1:18002`. + +### 6. Signing readiness probe + +Up to 5 attempts (3 s apart) to POST a 32-byte hex message to `/api/sign`. Success confirms the threshold is reachable and the relay subscription is active on both sides. Setup aborts if signing never succeeds. + +### 7. Create a persistent API key + +`POST /api/admin/api-keys { label: 'smoke-test-key' }` — the returned token is saved in `state.json` as `apiKey` and used in tests that verify API key authentication. + +### 8. Write shared state + +Everything is serialized to `$TMPDIR/igloo-smoke-test/state.json` and the path is exported as `SMOKE_STATE_FILE`. Every spec file calls `loadState()` at module level to read this file. + +## Shared State (`state.ts`) + +`loadState()` reads `SMOKE_STATE_FILE`. During Playwright's test discovery phase (when `--list` is run or the config is imported without a server running) `SMOKE_STATE_FILE` is not set, so the function returns a harmless stub with empty strings. Tests only execute after global-setup has populated the real state. + +```typescript +interface SmokeTestState { + port: number; + baseUrl: string; + tmpDir: string; + serverPid: number; + cosignerPid: number; + sessionId: string; // live admin session from global-setup login + apiKey: string | null; // DB-backed API key token + apiKeyId: string | null; + groupCredential: string; + shareCredentials: string[]; // [0] = server share, [1] = cosigner share + groupPubkeyHex: string; // x-only (no 02/03 prefix) + adminUsername: string; + adminPassword: string; + adminSecret: string; +} +``` + +## Spec Coverage + +### `01-auth.e2e.ts` — Authentication + +- `GET /api/auth/status` returns available auth methods +- `POST /api/auth/login` — valid credentials return `sessionId` +- `POST /api/auth/login` — wrong/unknown password returns 401 +- `GET /api/peers` — no auth returns 401 *(uses `/api/peers`, not `/api/status` — see design decisions below)* +- `GET /api/status` — valid session and API key (X-API-Key and Bearer formats) return 200 +- `GET /api/peers` — invalid API key returns 401 +- `POST /api/auth/logout` — invalidates session; subsequent `GET /api/peers` returns 401 + +### `02-status-peers.e2e.ts` — Status and Peers + +- `GET /api/status` — publicly accessible without auth (intentional design; returns 200) +- `GET /api/status` — with session returns full node info: `serverRunning`, `nodeActive`, `health`, `relayCount`, `timestamp` +- `GET /api/status` — health object has `isConnected`, `consecutiveConnectivityFailures` +- `GET /api/peers` — 401 without auth +- `GET /api/peers` — returns peer list with `peers`, `total`, `online` +- `GET /api/peers/group` — returns `pubkey` (matches `state.groupPubkeyHex`), `threshold` +- `GET /api/peers/self` — returns own share pubkey + +### `03-nip44-nip04.e2e.ts` — NIP-44 and NIP-04 Encryption + +NIP-44: +- 401 without auth +- Encrypt returns ciphertext +- Encrypt → decrypt round-trips plaintext +- Invalid `peer_pubkey` returns 400 +- Missing `content` returns 400 + +NIP-04: +- 401 without auth +- Encrypt returns ciphertext with IV suffix (NIP-04 format: `?iv=`) +- Encrypt → decrypt round-trips plaintext +- Invalid `peer_pubkey` returns 400 + +Uses `state.groupPubkeyHex` as the peer pubkey for encryption (the server encrypts to itself for round-trip tests). + +### `04-sign.e2e.ts` — Threshold Signing + +- 401 without auth +- 400 for non-hex message +- 400 for message shorter than 32 bytes +- 400 for missing body +- Signs a 32-byte hex message; response contains `sig` and `pubkey` +- Signs a full Nostr event object (with `id`, `content`, `kind`, `created_at`, `tags`) +- Signs with API key auth (`X-API-Key` header) — confirms DB-backed API keys work for signing +- 400 for event with invalid pubkey + +Signing tests exercise the complete FROSTR threshold flow: server publishes a sign request over the relay, co-signer responds with a partial signature, server aggregates and returns the final signature. + +### `05-admin.e2e.ts` — Admin Endpoints + +API key management: +- `GET /api/admin/api-keys` returns list (includes key from global-setup) +- `POST /api/admin/api-keys` creates a key (returns 201 with `token`, `id`) +- New API key authenticates successfully +- Revoked API key returns 401: creates key → verify works on `/api/event-log` → revoke → verify 401 on `/api/event-log` + +User management: +- `GET /api/admin/users` returns users list; admin user is present +- `GET /api/admin/whoami` returns `userId` +- Both require auth (401 without) + +### `06-event-log.e2e.ts` — UI Event Log + +- `GET /api/event-log` — 401 without auth +- Returns `{ entries: [...] }` with valid shape (`type`, `message`, `timestamp`) +- Pagination: `?limit=5` returns ≤ 5 entries +- `GET /api/event-log/export` — streams NDJSON (`Content-Type: application/x-ndjson`); each line parses as valid JSON +- Export — 401 without auth + +### `07-env.e2e.ts` — Credential / Env Management + +- `GET /api/env` — 401 without auth +- `GET /api/env` with session — returns `{ hasCredentials: true, ... }` +- `POST /api/env` — invalid `GROUP_CRED` returns 400 +- `POST /api/env` — invalid `SHARE_CRED` returns 400 +- `POST /api/env` — invalid relay URL returns 400 +- `POST /api/env` — without auth returns 401 + +### `08-ui.e2e.ts` — Browser UI (Headless Chrome) + +Login page: +- `/` renders login form (username + password inputs visible) +- Login form fills credentials and reaches the dashboard (tabs visible) + +Authenticated app (each test logs in fresh via `beforeEach`): +- Signer tab is visible after login +- Configure tab is accessible (click navigates, inputs render) +- API Keys tab renders without "Something went wrong" +- Event Log collapsible section (inside Signer tab, not a separate tab) is visible, click-to-expand works, no errors +- Logout button signs out and returns to login form + +Onboarding: +- `/` does not show "Admin Secret" text when DB is already initialized + +## Design Decisions and Gotchas + +### `/api/status` is intentionally public + +`/api/status` bypasses the main authentication check in `src/routes/index.ts`: + +```typescript +const isStatusEndpoint = url.pathname === '/api/status'; +// Auth check skips status: +if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && ...) { +``` + +This is by design — unauthenticated health checks and monitoring probes must be able to reach the status endpoint. Consequently: +- Tests that verify 401 enforcement **must use a different endpoint** (e.g., `GET /api/peers` or `GET /api/event-log`). +- Tests that verify authenticated 200 responses can still use `/api/status` (they pass with or without auth). + +### DB API keys and per-user credential lookup + +Database-backed API keys authenticate via `authenticateDatabaseApiKey()` and return `userId: 'api-key:'` — a string, not a numeric DB row ID. Several routes in DB mode call `getCredentials(auth)` which requires a numeric `userId` to decrypt per-user credentials from SQLite. If `userId` is not numeric, `getCredentials` returns `null` and the route responds 401. + +Affected routes: `GET /api/peers`, `GET /api/peers/group`, `GET /api/peers/self`. +Unaffected: `GET /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). + +For this reason: +- The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. +- The "new API key can authenticate" test uses `GET /api/status` (public) which trivially returns 200; this confirms the key is created but does not actually exercise DB-key auth enforcement. + +### Event log export is NDJSON, not JSON + +`GET /api/event-log/export` returns `Content-Type: application/x-ndjson` with one JSON object per line (newline-delimited JSON). Calling `response.json()` on this response fails because the body as a whole is not valid JSON. The test reads the body as text and parses each line individually: + +```typescript +const text = await res.text(); +const lines = text.trim().split('\n').filter(Boolean); +for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); +} +``` + +### `POST /api/env` validates credential format in DB mode + +The DB-mode `POST /api/env` handler (in `src/routes/env.ts`) validates `GROUP_CRED` and `SHARE_CRED` using `validateGroup()` / `validateShare()` from `@frostr/igloo-core` before writing to the `.env` file. Invalid credentials return 400. This validation was added during test development; it was previously only present on the headless `/api/env/shares` path. + +### Event Log is embedded in Signer tab, not a top-level tab + +The application has four top-level tabs: **Signer**, **NIP-46**, **API Keys**, **Recover**. There is no "Event Log" tab. The event log is a collapsible section within the Signer tab rendered as a `div[role="button"]` containing a `Event Log`. The UI test locates it with: + +```typescript +page.locator('[role="button"]:has-text("Event Log")').first() +``` + +### `.env` file interference with test server + +Bun loads `.env` from the current working directory automatically. A developer's `.env` may contain `GROUP_CRED`, `SHARE_CRED`, or `RELAYS` pointing to a production relay or a different port. If these leak into the test server's environment, the server creates a bifrost node at startup using the old credentials (different relay URL), and when the test then POSTs new credentials the server logs "Node already running, skipping restart" and stays connected to the wrong relay. The co-signer connects to the test relay, the server connects elsewhere — signing always times out. + +**Fix**: global-setup passes explicit empty-string overrides for all three variables when spawning the server: +```typescript +GROUP_CRED: '', +SHARE_CRED: '', +RELAYS: '', +``` + +### nostr-tools 2.x REQ filter format + +`@frostr/igloo-core` (which depends on `nostr-tools` 2.x) sends REQ messages in the format: +```json +["REQ", "sub_id", [{"kinds":[20004],"#p":[""]}]] +``` +Note the **array-wrapped filter** as the third element. NIP-01 expects filters as positional arguments: +```json +["REQ", "sub_id", {"kinds":[20004],"#p":[""]}] +``` + +The built-in relay (`src/class/relay.ts`) normalizes this in `_handler`: +```typescript +if (payload.length === 2 && Array.isArray(payload[1])) { + payload = [payload[0], ...payload[1]]; +} +``` + +Without this fix, the server's relay would reject all subscriptions from the bifrost node (logging "bad req: provided filter is not an object") and signing would always time out. + +## Temp Directory Layout + +Each run creates a fresh temp directory at `$TMPDIR/igloo-smoke-test/` (deleted by teardown): + +``` +igloo-smoke-test/ +├── db/ # SQLite database files (igloo.db, .session-secret) +├── state.json # Shared test state (pids, session, credentials, etc.) +├── server.log # igloo-server stdout/stderr +└── cosigner.log # co-signer subprocess stdout/stderr +``` + +If a run fails unexpectedly (e.g., setup throws before teardown registers), the temp dir may be left behind. It is safe to delete manually. + +## Adding New Tests + +1. Create `tests/e2e/specs/NN-name.e2e.ts`. +2. Import `loadState` from `../state.js` and call it at module level. +3. Use `state.sessionId` for session-authenticated requests, `state.apiKey` for API key requests. +4. Add the spec to the correct project in `playwright.config.ts` (update `testMatch` if needed, or rely on the `0[1-7]-*.e2e.ts` glob for API specs). +5. If testing a credential-sensitive endpoint in DB mode (peers, env), use `state.sessionId` — DB API keys cannot look up per-user credentials. + +## Files + +| File | Role | +|---|---| +| `tests/e2e/global-setup.ts` | Server lifecycle, onboarding, state serialization | +| `tests/e2e/global-teardown.ts` | SIGTERM + temp dir cleanup | +| `tests/e2e/state.ts` | `SmokeTestState` type and `loadState()` | +| `tests/e2e/cosigner.mjs` | Minimal co-signer subprocess (ESM, no TUI) | +| `tests/e2e/specs/01-auth.e2e.ts` | Auth enforcement, login/logout | +| `tests/e2e/specs/02-status-peers.e2e.ts` | Node status, peer list | +| `tests/e2e/specs/03-nip44-nip04.e2e.ts` | NIP-44 / NIP-04 encrypt+decrypt | +| `tests/e2e/specs/04-sign.e2e.ts` | Threshold Schnorr signing | +| `tests/e2e/specs/05-admin.e2e.ts` | API key CRUD, revocation, user management | +| `tests/e2e/specs/06-event-log.e2e.ts` | Event log pagination and NDJSON export | +| `tests/e2e/specs/07-env.e2e.ts` | Credential/env endpoint validation | +| `tests/e2e/specs/08-ui.e2e.ts` | Headless Chrome SPA smoke tests | +| `playwright.config.ts` | Project config, timeout, reporter, globalSetup/Teardown | +| `src/routes/env.ts` | DB-mode POST validates GROUP_CRED / SHARE_CRED format | +| `src/class/relay.ts` | Normalizes nostr-tools 2.x double-wrapped REQ filters | +| `src/routes/utils.ts` | `ALLOW_LOCALHOST_RELAY` bypass for test relay URLs | diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index f4e19ae..d132d4b 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false` (or `RELAY_ALLOW_BENIGN_SWALLOW`) forces publish errors to surface. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset. Setting either to `false` forces publish errors to surface. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 8c3b8c9..22353b5 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -66,11 +66,11 @@ These values are set in the store compose and expected by the UI flow: ## Operational Notes - Healthcheck uses `curl http://localhost:8002/api/status` with retries and start period. -- The Umbrel store uses a pinned digest to avoid tag caching issues; update the digest on each new release. -- `packages/umbrel/igloo/docker-compose.yml` remains a sideload/dev bundle and still points at `:umbrel-dev` without a digest. +- The Umbrel store `docker-compose.yml` intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. +- `packages/umbrel/igloo/docker-compose.yml` is a sideload/dev bundle and also points at `:umbrel-dev` but without a pinned digest. ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update `igloo-server-store/igloo-server/docker-compose.yml` to the new image digest. +2. Update the digest in `igloo-server-store/igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). 3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/package.json b/package.json index aed086c..822551e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,11 @@ "api:test:get:openapi": "bun scripts/api/test-get-openapi-sweep.ts", "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", + "test:unit": "bun test --max-concurrency=1 src tests/routes", + "test:e2e": "npx playwright test", + "test:e2e:ui": "npx playwright test --project=ui", + "test:e2e:api": "npx playwright test --project=api", + "test:e2e:report": "npx playwright show-report", "typecheck": "tsc --noEmit", "tsc": "tsc --noEmit" }, @@ -58,6 +63,7 @@ "yaml": "^2.8.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..41e3ff2 --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..384184a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e/specs', + globalSetup: './tests/e2e/global-setup.ts', + globalTeardown: './tests/e2e/global-teardown.ts', + + // Run all tests sequentially – they share one live server + co-signer process + fullyParallel: false, + workers: 1, + + // Retry once on CI to absorb timing flakes + retries: process.env.CI ? 1 : 0, + + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + + use: { + baseURL: 'http://localhost:18002', + trace: 'on-first-retry', + // Longer default for operations that wait on bifrost relay round-trips + actionTimeout: 15_000, + }, + + projects: [ + // Pure API specs (01–07) – use request context only, no browser + { + name: 'api', + testMatch: ['**/0[1-7]-*.e2e.ts'], + }, + // Browser UI spec (08) – needs a real browser + { + name: 'ui', + testMatch: ['**/08-ui.e2e.ts'], + use: { ...devices['Desktop Chrome'], headless: true }, + }, + ], + + // Global per-test timeout – sign tests can take up to 15 s + timeout: 30_000, +}); diff --git a/scripts/release.sh b/scripts/release.sh index 107ff4f..e161490 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -28,8 +28,12 @@ echo "📥 Pulling latest changes..." git pull origin dev # Run tests and build -echo "🔨 Building project..." +echo "🧪 Running type checks and backend tests..." bun install +bun run typecheck +bun run test:unit + +echo "🔨 Building project..." bun run build echo "✅ Testing server startup..." diff --git a/src/class/relay.ts b/src/class/relay.ts index e849402..f4dab29 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -152,6 +152,12 @@ class RelaySession { switch (verb) { case 'REQ': + // Normalize nostr-tools 2.x format where filters are wrapped in an extra array: + // New format: ["REQ", "sub_id", [{filter1}, {filter2}]] + // NIP-01 format: ["REQ", "sub_id", {filter1}, {filter2}] + if (payload.length === 2 && Array.isArray(payload[1])) { + payload = [payload[0], ...payload[1]] + } const [ id, ...filters ] = sub_schema.parse(payload) return this._onreq(id, filters) case 'EVENT': @@ -185,7 +191,8 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.debug('event failed validation:', event) + this.log.info('event failed validation (id=' + event.id.slice(0, 8) + ' kind=' + event.kind + ')') + this.log.debug('event details:', event) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } @@ -210,7 +217,7 @@ class RelaySession { this.log.client('received subscription request:', sub_id) this.log.debug('filters:', filters) // Add the subscription to our set. - this.addSub(sub_id, filters) + this.addSub(sub_id, ...filters) // For each filter: for (const filter of filters) { // Set the limit count, if any. @@ -254,7 +261,7 @@ class RelaySession { } remSub (subId : string) { - this.relay.subs.delete(subId) + this.relay.subs.delete(`${this.sid}/${subId}`) this._subs.delete(subId) } diff --git a/src/routes/env.ts b/src/routes/env.ts index cdd2e64..d376827 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -283,6 +283,20 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged } } + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED) { + const groupValidation = validateGroup(body.GROUP_CRED); + if (!groupValidation.isValid) { + return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); + } + } + + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED) { + const shareValidation = validateShare(body.SHARE_CRED); + if (!shareValidation.isValid) { + return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); + } + } + // DB mode privilege gate for env writes (no legacy fallback): // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or // - allow when the authenticated DB user has role=admin. diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 732859d..b1e31b4 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -60,11 +60,13 @@ export function getValidRelays( } // Validate each relay URL and exclude localhost to avoid conflicts + const allowLocalhost = process.env['ALLOW_LOCALHOST_RELAY'] === 'true'; const validRelays = relayList.filter(relay => { try { const url = new URL(relay); // Exclude localhost relays to avoid conflicts with our server - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + // (unless explicitly allowed, e.g. for testing) + if (!allowLocalhost && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) { console.warn(`Excluding localhost relay to avoid conflicts: ${relay}`); return false; } @@ -96,7 +98,7 @@ export function getValidRelays( } // Helper functions for .env file management -const ENV_FILE_PATH = '.env'; +const ENV_FILE_PATH = process.env.ENV_FILE_PATH?.trim() || '.env'; // Security: Whitelist of allowed environment variable keys (for write/validation) // IMPORTANT: SESSION_SECRET must NEVER be included here - it's strictly server-only diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs new file mode 100644 index 0000000..2c65947 --- /dev/null +++ b/tests/e2e/cosigner.mjs @@ -0,0 +1,61 @@ +/** + * Minimal FROSTR co-signer for smoke tests. + * + * Usage: node cosigner.mjs + */ + +const [,, groupCred, shareCred, relayUrl] = process.argv; + +if (!groupCred || !shareCred || !relayUrl) { + console.error('Usage: cosigner.mjs '); + process.exit(1); +} + +const { + createBifrostNode, + connectNode, +} = await import('../../node_modules/@frostr/igloo-core/dist/index.js'); + +let node; +try { + node = createBifrostNode({ + group: groupCred, + share: shareCred, + relays: [relayUrl], + }, { enableLogging: false }); + + node.on('ready', () => { + console.log('[cosigner] Node ready. PubKey:', node.pubkey?.slice(0, 16)); + console.log('[cosigner] Peers:', node.peers.map(p => p.pubkey?.slice(0, 16)).join(', ')); + }); + node.on('closed', () => console.log('[cosigner] Node closed')); + node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); + node.on('bounced', (...args) => console.log('[cosigner] Bounced:', JSON.stringify(args).slice(0, 200))); + node.on('message', (msg) => { + console.log('[cosigner] Message tag:', msg?.tag, '| from:', msg?.env?.pubkey?.slice(0,16)); + }); + node.on('/sign/handler/req', (msg) => console.log('[cosigner] SIGN REQ received, id:', msg?.id)); + node.on('/sign/handler/res', () => console.log('[cosigner] SIGN RES sent')); + node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', JSON.stringify(a).slice(0, 200))); + + // Also spy on the raw WebSocket to confirm relay subscription + node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', JSON.stringify(a).slice(0, 100))); + + console.log('[cosigner] Connecting to relay:', relayUrl); + await connectNode(node); + console.log('[cosigner] Connected. Pubkey:', node.pubkey); + console.log('[cosigner] Filter:', JSON.stringify(node.client?._filter ?? node.client?.filter ?? '?')); + +} catch (err) { + console.error('[cosigner] Failed to start:', err.message ?? err); + process.exit(2); +} + +const shutdown = () => { + try { node?.close?.(); } catch {} + process.exit(0); +}; +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +setInterval(() => {}, 60_000); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..f621331 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,284 @@ +/** + * Playwright global setup for igloo-server DB-mode smoke tests. + * + * What this does: + * 1. Generate a 2-of-3 FROSTR keyset using @frostr/igloo-core (fixed nsec). + * 2. Start igloo-server on port 18002 with a fresh temp SQLite DB. + * 3. Complete DB-mode onboarding (validate-admin → setup → login). + * 4. POST /api/user/credentials (share-0 + group) to start the bifrost node. + * 5. Launch a minimal co-signer (cosigner.mjs) with share-1 via igloo-core. + * 6. Probe signing to confirm the threshold is reachable. + * 7. Create a persistent test API key. + * 8. Write all shared state to a JSON file; export SMOKE_STATE_FILE env var. + */ + +import { request } from '@playwright/test'; +import { spawn } from 'child_process'; +import type { ChildProcess } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import type { FullConfig } from '@playwright/test'; + +// ─── Test constants ────────────────────────────────────────────────────────── + +const PORT = 18002; +const BASE_URL = `http://localhost:${PORT}`; +const TMP_DIR = path.join(os.tmpdir(), 'igloo-smoke-test'); +const STATE_FILE = path.join(TMP_DIR, 'state.json'); +const DB_PATH = path.join(TMP_DIR, 'db'); +const SERVER_LOG = path.join(TMP_DIR, 'server.log'); +const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); + +// A fixed, deterministic 32-byte secp256k1 private key (well below curve order) +const TEST_NSEC_HEX = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + +// Meets igloo-server password rules: upper + lower + digit + special(@), no sequences +const ADMIN_SECRET = 'SmokeTestAdmin1'; +const ADMIN_USERNAME = 'testadmin'; +const ADMIN_PASSWORD = 'T3stPass@9'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function pollUntil( + fn: () => Promise, + timeoutMs: number, + intervalMs = 1000, + label = 'condition', +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + if (await fn()) return; + } catch { + // ignore, keep polling + } + await sleep(intervalMs); + } + throw new Error(`Timed out waiting for: ${label}`); +} + +async function waitForHttp(url: string, timeoutMs: number): Promise { + await pollUntil( + async () => { + const res = await fetch(url).catch(() => null); + return res !== null && res.status < 500; + }, + timeoutMs, + 500, + `HTTP ${url}`, + ); +} + +function spawnDetached( + cmd: string, + args: string[], + env: NodeJS.ProcessEnv, + logFile: string, +): ChildProcess { + const out = fs.openSync(logFile, 'a'); + const proc = spawn(cmd, args, { + env: { ...process.env, ...env }, + detached: false, + stdio: ['ignore', out, out], + }); + proc.on('error', err => { + fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); + }); + return proc; +} + +// ─── Global setup ──────────────────────────────────────────────────────────── + +export default async function globalSetup(_config: FullConfig): Promise { + // Fresh temp directory every run + if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(TMP_DIR, { recursive: true }); + fs.mkdirSync(DB_PATH, { recursive: true }); + + // ── 1. Generate FROSTR credentials via igloo-core ────────────────────────── + console.log('[setup] Generating FROSTR credentials…'); + // Dynamic import so TS type-checker doesn't complain about the JS dist path + const { generateKeysetWithSecret, decodeGroup } = await import( + /* @ts-ignore */ + '../../node_modules/@frostr/igloo-core/dist/index.js' + ) as { + generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; + decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; + }; + + // Use 2-of-2 (not 2-of-3) so signing always selects the one connected cosigner. + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); + const group = decodeGroup(groupCredential); + // x-only pubkey (strip 02/03 compression prefix) for NIP-44/NIP-04 tests + const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); + + + // ── 3. Start igloo-server ────────────────────────────────────────────────── + console.log('[setup] Starting igloo-server on port', PORT, '…'); + const serverProcess = spawnDetached( + 'bun', + ['run', 'src/server.ts'], + { + ADMIN_SECRET, + DB_PATH, + HOST_PORT: String(PORT), + HOST_NAME: '127.0.0.1', + RATE_LIMIT_ENABLED: 'false', + SKIP_RELAY_PROBE: 'true', + SKIP_STARTUP_ECHO: 'true', + NODE_ENV: 'test', + AUTH_ENABLED: 'true', + FROSTR_SIGN_TIMEOUT: '15000', + UI_EVENT_LOG_INCLUDE_PINGS: 'false', + UPDATE_CHECK_DISABLED: 'true', + ALLOW_LOCALHOST_RELAY: 'true', + // Clear any .env credentials so the server starts without pre-loaded creds + GROUP_CRED: '', + SHARE_CRED: '', + RELAYS: '', + }, + SERVER_LOG, + ); + + await waitForHttp(`${BASE_URL}/api/onboarding/status`, 20_000); + console.log('[setup] Server is up.'); + + // ── 4. Onboarding ────────────────────────────────────────────────────────── + console.log('[setup] Running onboarding…'); + const api = await request.newContext({ baseURL: BASE_URL }); + + let res = await api.post('/api/onboarding/validate-admin', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + }); + if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); + + res = await api.post('/api/onboarding/setup', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); + + // ── 5. Login ─────────────────────────────────────────────────────────────── + console.log('[setup] Logging in…'); + res = await api.post('/api/auth/login', { + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); + const { sessionId } = (await res.json()) as { sessionId: string }; + + // ── 6. Set FROSTR credentials (share-0 + group) ──────────────────────────── + // In DB mode credentials are stored per-user (encrypted) via /api/user/credentials, + // NOT via /api/env (which only writes to .env file and does not start the node). + console.log('[setup] Setting FROSTR credentials on server…'); + res = await api.post('/api/user/credentials', { + headers: { 'X-Session-ID': sessionId }, + data: { + group_cred: groupCredential, + share_cred: shareCredentials[0], + relays: [`ws://127.0.0.1:${PORT}`], + }, + }); + if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); + + // Wait for the bifrost node to go active + await pollUntil( + async () => { + const s = await api.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); + if (!s.ok()) return false; + const body = (await s.json()) as { nodeActive: boolean }; + return body.nodeActive === true; + }, + 15_000, + 1000, + 'nodeActive = true', + ); + console.log('[setup] Node is active.'); + + // ── 7. Start co-signer (share index 1) using igloo-core directly ────────── + // We use a minimal cosigner.mjs script so we control which share credentials + // are used and avoid igloo-cli's interactive TUI entirely. + // Server holds shareCredentials[0]; co-signer holds shareCredentials[1]. + console.log('[setup] Starting co-signer with shareCredentials[1]…'); + const cosignerProcess = spawnDetached( + 'node', + [ + path.resolve('tests/e2e/cosigner.mjs'), + groupCredential, + shareCredentials[1], + `ws://127.0.0.1:${PORT}`, + ], + {}, + COSIGNER_LOG, + ); + console.log('[setup] Co-signer pid:', cosignerProcess.pid); + + // ── 8. Signing readiness probe ───────────────────────────────────────────── + // Retry signing a known 32-byte hex to confirm threshold is reachable + console.log('[setup] Probing signing (waiting for co-signer to join relay)…'); + const TEST_MSG = 'a'.repeat(64); // 32-byte all-0xAA event id + let signOk = false; + for (let attempt = 1; attempt <= 5; attempt++) { + await sleep(3000); + const sr = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: TEST_MSG }, + }).catch(() => null); + if (sr && sr.ok()) { + signOk = true; + console.log(`[setup] Signing OK on attempt ${attempt}.`); + break; + } + const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; + console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); + } + if (!signOk) { + const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; + throw new Error(`Co-signer did not become ready within 25 s.\nCo-signer log:\n${cosLog}`); + } + + // ── 9. Create persistent test API key ───────────────────────────────────── + console.log('[setup] Creating test API key…'); + res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'smoke-test-key' }, + }); + let apiKey: string | null = null; + let apiKeyId: string | null = null; + if (res.ok()) { + const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; + apiKey = body.apiKey.token; + apiKeyId = String(body.apiKey.id); + } else { + console.warn('[setup] Could not create API key – admin key tests will be skipped.'); + } + + await api.dispose(); + + // ── 10. Persist shared state ─────────────────────────────────────────────── + const state = { + port: PORT, + baseUrl: BASE_URL, + tmpDir: TMP_DIR, + serverPid: serverProcess.pid, + cosignerPid: cosignerProcess.pid, + sessionId, + apiKey, + apiKeyId, + groupCredential, + shareCredentials, + groupPubkeyHex, + adminUsername: ADMIN_USERNAME, + adminPassword: ADMIN_PASSWORD, + adminSecret: ADMIN_SECRET, + }; + + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + process.env.SMOKE_STATE_FILE = STATE_FILE; + + console.log('[setup] ✓ Global setup complete. State saved to', STATE_FILE); +} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000..f4ffd72 --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,48 @@ +/** + * Playwright global teardown – kills the server + co-signer and cleans up + * the temp directory created by global-setup. + */ + +import fs from 'fs'; +import type { FullConfig } from '@playwright/test'; + +export default async function globalTeardown(_config: FullConfig): Promise { + const stateFile = process.env.SMOKE_STATE_FILE; + if (!stateFile || !fs.existsSync(stateFile)) { + console.warn('[teardown] No state file found – nothing to clean up.'); + return; + } + + let state: { serverPid?: number; cosignerPid?: number; tmpDir?: string }; + try { + state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + } catch { + console.warn('[teardown] Could not parse state file.'); + return; + } + + for (const [label, pid] of [['co-signer', state.cosignerPid], ['server', state.serverPid]] as const) { + if (!pid) continue; + try { + process.kill(pid, 'SIGTERM'); + console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); + } catch (err: unknown) { + // ESRCH = process already gone, which is fine + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { + console.warn(`[teardown] Could not kill ${label} (pid ${pid}):`, err); + } + } + } + + // Brief pause to let processes flush logs + await new Promise(r => setTimeout(r, 500)); + + if (state.tmpDir) { + try { + fs.rmSync(state.tmpDir, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', state.tmpDir); + } catch (err) { + console.warn('[teardown] Could not remove temp dir:', err); + } + } +} diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts new file mode 100644 index 0000000..91fba5c --- /dev/null +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -0,0 +1,117 @@ +/** + * Auth smoke tests – login, logout, session auth, API-key auth, 401 enforcement. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; + +test.describe('Auth – /api/auth', () => { + test('GET /api/auth/status returns enabled methods', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/auth/status'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + await api.dispose(); + }); + + test('POST /api/auth/login – valid credentials return sessionId', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('sessionId'); + expect(typeof body.sessionId).toBe('string'); + expect(body.sessionId.length).toBeGreaterThan(8); + await api.dispose(); + }); + + test('POST /api/auth/login – wrong password returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('POST /api/auth/login – unknown user returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/auth/login', { + data: { username: 'nobody', password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/peers – no auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/status – valid session returns 200', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/status – valid API key (X-API-Key) returns 200', async () => { + test.skip(!apiKey, 'No API key available'); + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-API-Key': apiKey! }, + }); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/status – valid API key (Bearer) returns 200', async () => { + test.skip(!apiKey, 'No API key available'); + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { Authorization: `Bearer ${apiKey!}` }, + }); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/peers – invalid API key returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers', { + headers: { 'X-API-Key': 'totally-invalid-key' }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('POST /api/auth/logout – returns 200 and clears session', async () => { + // Log in fresh so we don't burn the shared session + const api = await request.newContext({ baseURL: baseUrl }); + const loginRes = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + const { sessionId: tempSession } = await loginRes.json(); + + const logoutRes = await api.post('/api/auth/logout', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(logoutRes.status()).toBe(200); + + // The session should now be invalid + const afterRes = await api.get('/api/peers', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(afterRes.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts new file mode 100644 index 0000000..2190c8c --- /dev/null +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -0,0 +1,98 @@ +/** + * Status and peers smoke tests. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId } = state; + +test.describe('Status – /api/status', () => { + test('GET /api/status is publicly accessible without auth', async () => { + // /api/status intentionally allows unauthenticated health checks + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status'); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/status returns 200 with node info', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.serverRunning).toBe(true); + expect(body.nodeActive).toBe(true); + expect(body).toHaveProperty('health'); + expect(body).toHaveProperty('relayCount'); + expect(body).toHaveProperty('timestamp'); + await api.dispose(); + }); + + test('GET /api/status has valid health object', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + const body = await res.json(); + expect(body.health).toHaveProperty('isConnected'); + expect(typeof body.health.isConnected).toBe('boolean'); + expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); + await api.dispose(); + }); +}); + +test.describe('Peers – /api/peers', () => { + test('GET /api/peers returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/peers returns peer list', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('peers'); + expect(Array.isArray(body.peers)).toBe(true); + // 2-of-3 keyset: 2 remote peers (self filtered out) + expect(body.peers.length).toBeGreaterThanOrEqual(1); + expect(typeof body.total).toBe('number'); + expect(typeof body.online).toBe('number'); + await api.dispose(); + }); + + test('GET /api/peers/group returns group pubkey', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers/group', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(body.pubkey).toBe(state.groupPubkeyHex); + expect(typeof body.threshold).toBe('number'); + await api.dispose(); + }); + + test('GET /api/peers/self returns own share pubkey', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers/self', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(typeof body.pubkey).toBe('string'); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts new file mode 100644 index 0000000..cd118f4 --- /dev/null +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -0,0 +1,140 @@ +/** + * NIP-44 and NIP-04 encrypt/decrypt smoke tests. + * + * We use the group pubkey as the "peer" for ECDH operations – the server + * holds share-0 so it can derive the shared secret with any co-participant. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, groupPubkeyHex } = state; + +const PLAINTEXT = 'Hello from igloo smoke test!'; + +// ─── NIP-44 ────────────────────────────────────────────────────────────────── + +test.describe('NIP-44 – /api/nip44', () => { + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('encrypt returns ciphertext', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + expect(typeof body.result).toBe('string'); + expect(body.result.length).toBeGreaterThan(0); + await api.dispose(); + }); + + test('encrypt then decrypt round-trips plaintext', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + const encRes = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip44/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); + + await api.dispose(); + }); + + test('invalid peer_pubkey returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('missing content returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); +}); + +// ─── NIP-04 ────────────────────────────────────────────────────────────────── + +test.describe('NIP-04 – /api/nip04', () => { + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('encrypt returns ciphertext with IV suffix', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + // NIP-04 ciphertext has the form ?iv= + expect(body.result).toMatch(/\?iv=/); + await api.dispose(); + }); + + test('encrypt then decrypt round-trips plaintext', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + const encRes = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip04/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); + + await api.dispose(); + }); + + test('invalid peer_pubkey returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts new file mode 100644 index 0000000..8e8d943 --- /dev/null +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -0,0 +1,130 @@ +/** + * Signing smoke tests – requires the igloo-cli co-signer launched in global setup. + * + * sign timeout: 15 s (FROSTR_SIGN_TIMEOUT env set in global-setup). + * Test timeout overridden to 30 s to accommodate the signing round-trip. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, groupPubkeyHex } = state; + +// Valid 32-byte hex event IDs for signing +const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +test.describe('Sign – /api/sign', () => { + test.setTimeout(30_000); + + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('returns 400 for invalid (non-hex) message', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'not-hex' }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('returns 400 for message shorter than 32 bytes', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'deadbeef' }, // only 4 bytes + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('returns 400 for missing body', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: {}, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('signs a 32-byte hex message and returns signature', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('id', EVENT_ID_A); + expect(body).toHaveProperty('signature'); + expect(typeof body.signature).toBe('string'); + // Schnorr signature = 64 bytes = 128 hex chars + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + await api.dispose(); + }); + + test('signs a full event object and returns signature', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + // Use the group pubkey as the event author pubkey + const event = { + pubkey: groupPubkeyHex, + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'igloo smoke test', + tags: [], + }; + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { event }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('id'); + expect(body).toHaveProperty('signature'); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + await api.dispose(); + }); + + test('signing works with API key auth', async () => { + test.skip(!state.apiKey, 'No API key available'); + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-API-Key': state.apiKey! }, + data: { message: EVENT_ID_B }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + await api.dispose(); + }); + + test('event with invalid pubkey returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { + event: { + pubkey: 'not-64-hex', + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'bad', + tags: [], + }, + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts new file mode 100644 index 0000000..470be6e --- /dev/null +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -0,0 +1,138 @@ +/** + * Admin endpoint smoke tests: + * - API key creation, listing, revocation + * - User listing, whoami + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, adminUsername } = state; + +test.describe('Admin – API keys', () => { + test('GET /api/admin/api-keys returns list', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('apiKeys'); + expect(Array.isArray(body.apiKeys)).toBe(true); + // At minimum the key created in global setup should be here + expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); + await api.dispose(); + }); + + test('POST /api/admin/api-keys creates a new key', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'temp-test-key' }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('apiKey'); + expect(body.apiKey).toHaveProperty('token'); + expect(typeof body.apiKey.token).toBe('string'); + expect(body.apiKey.token.length).toBeGreaterThan(20); + expect(body.apiKey).toHaveProperty('id'); + await api.dispose(); + }); + + test('new API key can authenticate', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + // Create key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'auth-test-key' }, + }); + const { apiKey } = await createRes.json(); + + // Use key to hit a protected route + const authRes = await api.get('/api/status', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(authRes.status()).toBe(200); + + await api.dispose(); + }); + + test('revoked API key returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + // Create a fresh key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'revoke-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + + // Verify it works on an auth-protected endpoint + const beforeRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(beforeRes.status()).toBe(200); + + // Revoke it + const revokeRes = await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, + }); + expect(revokeRes.status()).toBe(200); + + // Now the revoked key should be rejected + const afterRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(afterRes.status()).toBe(401); + + await api.dispose(); + }); + + test('GET /api/admin/api-keys without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/api-keys'); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); + +test.describe('Admin – Users', () => { + test('GET /api/admin/users returns user list', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/users', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('users'); + expect(Array.isArray(body.users)).toBe(true); + expect(body.users.length).toBeGreaterThanOrEqual(1); + // Our admin user must be in the list + const found = body.users.some((u: { username: string }) => u.username === adminUsername); + expect(found).toBe(true); + await api.dispose(); + }); + + test('GET /api/admin/whoami returns admin identity', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/whoami', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('userId'); + await api.dispose(); + }); + + test('GET /api/admin/users without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/users'); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts new file mode 100644 index 0000000..4c85213 --- /dev/null +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -0,0 +1,86 @@ +/** + * UI event-log smoke tests. + * Signing operations performed in 04-sign.spec.ts will have produced log entries. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId } = state; + +test.describe('Event log – /api/event-log', () => { + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/event-log returns entries array', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('entries'); + expect(Array.isArray(body.entries)).toBe(true); + await api.dispose(); + }); + + test('entries have expected shape', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + const body = await res.json(); + + // If there are entries from sign tests, validate their shape + if (body.entries.length > 0) { + const entry = body.entries[0]; + expect(entry).toHaveProperty('type'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('timestamp'); + } + await api.dispose(); + }); + + test('pagination params are accepted', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log?limit=5', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.entries.length).toBeLessThanOrEqual(5); + await api.dispose(); + }); + + test('GET /api/event-log/export returns NDJSON', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log/export', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + + // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array + const contentType = res.headers()['content-type'] ?? ''; + expect(contentType).toContain('ndjson'); + + const text = await res.text(); + // Each non-empty line must be valid JSON + const lines = text.trim().split('\n').filter(Boolean); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + await api.dispose(); + }); + + test('export without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log/export'); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts new file mode 100644 index 0000000..0ced112 --- /dev/null +++ b/tests/e2e/specs/07-env.e2e.ts @@ -0,0 +1,85 @@ +/** + * Credential management smoke tests – /api/env. + * + * GET returns the current credential state. + * POST with invalid credentials returns 400. + * We do NOT update valid credentials here to avoid disrupting co-signer timing. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId } = state; + +test.describe('Env / credentials – /api/env', () => { + test('GET /api/env returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/env'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/env with session returns credential metadata', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/env', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + // In DB mode the response should indicate that credentials are present + expect(body).toHaveProperty('hasCredentials', true); + await api.dispose(); + }); + + test('POST /api/env – invalid GROUP_CRED returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: 'not-a-valid-bfgroup-credential', + SHARE_CRED: state.shareCredentials[0], + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('POST /api/env – invalid SHARE_CRED returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: 'not-a-valid-bfshare-credential', + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('POST /api/env – invalid relay URL returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: ['not-a-websocket-url'], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('POST /api/env without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts new file mode 100644 index 0000000..8c38bc5 --- /dev/null +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -0,0 +1,124 @@ +/** + * Browser UI smoke tests – uses Playwright's full browser to exercise the SPA. + * + * Prerequisites: `bun run build` must have been run so static/app.js exists. + * The server is already running (started in global-setup). + */ + +import { test, expect } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, adminUsername, adminPassword } = state; + +test.describe('UI – Login page', () => { + test('/ renders the login form when not authenticated', async ({ page }) => { + await page.goto(baseUrl); + // The SPA should show either the login form or onboarding + // Since onboarding is complete, we expect the login form + await expect(page).toHaveURL(baseUrl + '/'); + // Login form has username + password inputs + await expect(page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first()).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator('input[type="password"]').first()).toBeVisible(); + }); + + test('login form accepts credentials and navigates to dashboard', async ({ page }) => { + await page.goto(baseUrl); + + // Fill in the login form + const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + + await usernameField.fill(adminUsername); + await passwordField.fill(adminPassword); + + // Submit (button with type=submit or labeled "Login"/"Sign in") + const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); + await submitBtn.click(); + + // After login we should see the main app tabs + await expect(page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first()).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe('UI – Authenticated app', () => { + // Log in once per test block using page fixtures (each test gets a fresh page) + test.beforeEach(async ({ page }) => { + await page.goto(baseUrl); + + const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + await usernameField.fill(adminUsername); + await passwordField.fill(adminPassword); + const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); + await submitBtn.click(); + + // Wait for the app to load + await page.waitForLoadState('networkidle'); + }); + + test('Signer tab is visible and shows node status indicator', async ({ page }) => { + // The Signer tab or its content should be visible after login + const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); + await expect(signerTab).toBeVisible({ timeout: 8_000 }); + }); + + test('Configure tab is accessible', async ({ page }) => { + const configureTab = page + .locator('[role="tab"]:has-text("Configure"), button:has-text("Configure"), a:has-text("Configure")') + .first(); + await expect(configureTab).toBeVisible({ timeout: 8_000 }); + await configureTab.click(); + // After clicking, the configure panel content should appear + await page.waitForLoadState('networkidle'); + // Look for credential-related inputs or headings + const configContent = page.locator('input, textarea, [data-testid*="cred"]').first(); + await expect(configContent).toBeVisible({ timeout: 8_000 }); + }); + + test('API Keys tab is accessible', async ({ page }) => { + const apiKeysTab = page + .locator('[role="tab"]:has-text("API Keys"), [role="tab"]:has-text("Api Keys"), button:has-text("API Keys")') + .first(); + await expect(apiKeysTab).toBeVisible({ timeout: 8_000 }); + await apiKeysTab.click(); + await page.waitForLoadState('networkidle'); + // The tab panel should render without error + await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); + }); + + test('Event Log section is visible on Signer tab and shows no errors', async ({ page }) => { + // The Event Log is a collapsible section embedded in the Signer tab (not a top-level tab). + // It renders a div with role="button" and a span containing "Event Log". + const eventLogToggle = page.locator('[role="button"]:has-text("Event Log")').first(); + await expect(eventLogToggle).toBeVisible({ timeout: 8_000 }); + await eventLogToggle.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); + }); + + test('Logout button signs out and returns to login', async ({ page }) => { + // Find and click a logout button + const logoutBtn = page + .locator('button:has-text("Logout"), button:has-text("Sign out"), a:has-text("Logout"), [aria-label*="logout" i]') + .first(); + await expect(logoutBtn).toBeVisible({ timeout: 8_000 }); + await logoutBtn.click(); + await page.waitForLoadState('networkidle'); + + // Should be back at the login form + await expect(page.locator('input[type="password"]').first()).toBeVisible({ timeout: 8_000 }); + }); +}); + +test.describe('UI – Onboarding already completed', () => { + test('/ does not show onboarding when DB is initialised', async ({ page }) => { + await page.goto(baseUrl); + // The onboarding "ADMIN_SECRET" or "setup" copy should NOT appear + await expect(page.locator('body')).not.toContainText('Admin Secret', { timeout: 6_000 }); + }); +}); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts new file mode 100644 index 0000000..76616f5 --- /dev/null +++ b/tests/e2e/state.ts @@ -0,0 +1,51 @@ +import fs from 'fs'; + +export interface SmokeTestState { + port: number; + baseUrl: string; + tmpDir: string; + serverPid: number; + cosignerPid: number; + sessionId: string; + apiKey: string | null; + apiKeyId: string | null; + groupCredential: string; + shareCredentials: string[]; + groupPubkeyHex: string; // x-only (no 02/03 prefix), used for NIP-44/NIP-04 + adminUsername: string; + adminPassword: string; + adminSecret: string; +} + +const STUB: SmokeTestState = { + port: 18002, + baseUrl: 'http://localhost:18002', + tmpDir: '', + serverPid: 0, + cosignerPid: 0, + sessionId: '', + apiKey: null, + apiKeyId: null, + groupCredential: '', + shareCredentials: [], + groupPubkeyHex: '', + adminUsername: '', + adminPassword: '', + adminSecret: '', +}; + +/** + * Load shared test state written by global-setup. + * During test discovery (--list) or if SMOKE_STATE_FILE is not yet set, returns + * a harmless stub so that module-level const initialisations succeed. + * The real values are always present when tests actually execute. + */ +export function loadState(): SmokeTestState { + const stateFile = process.env.SMOKE_STATE_FILE; + if (!stateFile) return STUB; + try { + return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SmokeTestState; + } catch { + return STUB; + } +} diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 7eea3ae..87db273 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -5,16 +5,68 @@ import { pathToFileURL } from 'url'; export const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; +const ISOLATED_ENV_KEYS = [ + 'NODE_ENV', + 'HEADLESS', + 'AUTH_ENABLED', + 'API_KEY', + 'BASIC_AUTH_USER', + 'BASIC_AUTH_PASS', + 'GROUP_CRED', + 'SHARE_CRED', + 'GROUP_NAME', + 'RELAYS', + 'PEER_POLICIES', + 'DB_PATH', + 'ADMIN_SECRET', + 'SESSION_SECRET', + 'ALLOWED_ORIGINS', + 'TRUST_PROXY', + 'AUTO_ADMIN_SECRET', + 'SKIP_ADMIN_SECRET_VALIDATION', + 'ENV_FILE_PATH', +]; + +const ISOLATED_ENV_PREFIXES = [ + 'RATE_LIMIT_', +]; + +function buildScriptEnv(overrides: Record): Record { + const nextEnv: Record = { ...process.env } as Record; + + for (const key of ISOLATED_ENV_KEYS) { + delete nextEnv[key]; + } + + for (const key of Object.keys(nextEnv)) { + if (ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) { + delete nextEnv[key]; + } + } + + return { + ...nextEnv, + NODE_ENV: 'test', + ...overrides, + }; +} + export function runRouteScript(code: string, env: Record = {}) { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); writeFileSync(runner, code, 'utf8'); + const isolatedEnv = buildScriptEnv({ + ENV_FILE_PATH: path.join(tmpDir, '.env'), + DB_PATH: path.join(tmpDir, 'igloo.db'), + ...env + }); + const result = Bun.spawnSync({ - cmd: ['bun', 'run', runner], + cmd: ['bun', '--no-env-file', 'run', runner], cwd: process.cwd(), - env: { ...process.env, ...env }, + env: isolatedEnv, stdout: 'pipe', stderr: 'pipe', timeout: 15000, From ac2649647df47ebff12652f667fc093f646bfb70 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 20:09:01 -0600 Subject: [PATCH 02/32] fix test placeholder strings --- tests/routes/env.db-mode.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 2b331fd..91d1c2c 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -77,11 +77,15 @@ describe('DB-mode /api/env behavior', () => { updateNode: () => {} }; + // Generate real FROSTR credentials so validateGroup/validateShare pass + const { generateKeysetWithSecret } = await import(root + 'node_modules/@frostr/igloo-core/dist/index.js'); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + const headers = new Headers({ 'Content-Type': 'application/json', 'X-Admin-Secret': 'test-admin-secret' }); - const body = { GROUP_CRED: 'group-cred-stub', SHARE_CRED: 'share-cred-stub' }; + const body = { GROUP_CRED: groupCredential, SHARE_CRED: shareCredentials[0] }; const req = new Request('http://localhost/api/env', { method: 'POST', headers, body: JSON.stringify(body) }); const res = await handleEnvRoute(req, new URL(req.url), context, { authenticated: true, userId: 2 }); From c579a3ef4cd518966482d5ac0b07e7e3b977ed31 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 20:39:34 -0600 Subject: [PATCH 03/32] test: add Playwright E2E smoke suite and patch dev dep vulnerabilities --- .github/workflows/ci.yml | 4 +- bun.lock | 103 ++++--- package.json | 13 +- tests/e2e/global-setup.ts | 462 +++++++++++++++++++------------- tests/e2e/global-teardown.ts | 37 ++- tests/e2e/specs/01-auth.e2e.ts | 8 +- tests/e2e/specs/05-admin.e2e.ts | 4 +- 7 files changed, 370 insertions(+), 261 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1245aae..d4bfef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: - name: Type check run: bun run tsc --noEmit - - name: Run backend tests - run: bun run test:unit + - name: Run route tests + run: bun test tests/routes - name: Build frontend run: bun run build diff --git a/bun.lock b/bun.lock index 079d0ee..c9f0e76 100644 --- a/bun.lock +++ b/bun.lock @@ -32,9 +32,10 @@ "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", "@types/yaml": "^1.9.7", + "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", - "esbuild": "^0.24.2", + "esbuild": "^0.25.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", @@ -42,6 +43,14 @@ }, }, }, + "overrides": { + "ajv": "^8.18.0", + "fast-xml-parser": "^5.3.6", + "glob": "^10.5.0", + "js-yaml": "^4.1.1", + "minimatch": "^10.2.1", + "undici": "^6.23.0", + }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -65,55 +74,57 @@ "@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], @@ -283,7 +294,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -299,13 +310,13 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], "better-ajv-errors": ["better-ajv-errors@1.2.0", "", { "dependencies": { "@babel/code-frame": "^7.16.0", "@humanwhocodes/momoa": "^2.0.2", "chalk": "^4.1.2", "jsonpointer": "^5.0.0", "leven": "^3.1.0 < 4" }, "peerDependencies": { "ajv": "4.11.8 - 8" } }, "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -345,8 +356,6 @@ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], @@ -403,7 +412,7 @@ "es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="], - "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -419,7 +428,7 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], + "fast-xml-parser": ["fast-xml-parser@5.3.7", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA=="], "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], @@ -431,8 +440,6 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -445,7 +452,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -465,8 +472,6 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], @@ -503,7 +508,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -545,7 +550,7 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -591,8 +596,6 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "openapi-sampler": ["openapi-sampler@1.6.2", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" } }, "sha512-NyKGiFKfSWAZr4srD/5WDhInOWDhfml32h/FKUqLpEwKJt0kG0LGUU0MdyNkKrVGuJnw6DuPWq/sHCwAMpiRxg=="], @@ -603,8 +606,6 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -741,7 +742,7 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], "styled-components": ["styled-components@6.1.19", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA=="], @@ -781,7 +782,7 @@ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -805,8 +806,6 @@ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -853,8 +852,6 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], "nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -879,8 +876,6 @@ "styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], - "swagger2openapi/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "swagger2openapi/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -897,16 +892,12 @@ "concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "oas-resolver/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "oas-resolver/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "swagger2openapi/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "swagger2openapi/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/package.json b/package.json index 822551e..02cdb89 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", "test:unit": "bun test --max-concurrency=1 src tests/routes", + "test:e2e:smoke": "npx playwright test --project=api tests/e2e/specs/01-auth.e2e.ts tests/e2e/specs/04-sign.e2e.ts tests/e2e/specs/05-admin.e2e.ts", "test:e2e": "npx playwright test", + "test:e2e:nightly": "npx playwright test", "test:e2e:ui": "npx playwright test --project=ui", "test:e2e:api": "npx playwright test --project=api", "test:e2e:report": "npx playwright show-report", @@ -69,12 +71,21 @@ "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", "@types/yaml": "^1.9.7", + "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", - "esbuild": "^0.24.2", + "esbuild": "^0.25.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.3" + }, + "overrides": { + "glob": "^10.5.0", + "minimatch": "^10.2.1", + "js-yaml": "^4.1.1", + "undici": "^6.23.0", + "ajv": "^8.18.0", + "fast-xml-parser": "^5.3.6" } } diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index f621331..bbc1534 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -1,30 +1,31 @@ /** - * Playwright global setup for igloo-server DB-mode smoke tests. + * Playwright global setup for DB-mode smoke tests. * * What this does: - * 1. Generate a 2-of-3 FROSTR keyset using @frostr/igloo-core (fixed nsec). - * 2. Start igloo-server on port 18002 with a fresh temp SQLite DB. - * 3. Complete DB-mode onboarding (validate-admin → setup → login). - * 4. POST /api/user/credentials (share-0 + group) to start the bifrost node. - * 5. Launch a minimal co-signer (cosigner.mjs) with share-1 via igloo-core. - * 6. Probe signing to confirm the threshold is reachable. - * 7. Create a persistent test API key. - * 8. Write all shared state to a JSON file; export SMOKE_STATE_FILE env var. + * 1. Generate a deterministic 2-of-2 FROSTR keyset. + * 2. Start igloo-server against a fresh temporary DB path. + * 3. Complete onboarding and login. + * 4. Persist user credentials to start the Bifrost node. + * 5. Start a real co-signer process. + * 6. Probe signing readiness. + * 7. Create a reusable API key for auth tests. + * 8. Persist shared state for specs and teardown. */ import { request } from '@playwright/test'; +import type { APIRequestContext, FullConfig } from '@playwright/test'; import { spawn } from 'child_process'; import type { ChildProcess } from 'child_process'; -import path from 'path'; import fs from 'fs'; +import net from 'net'; import os from 'os'; -import type { FullConfig } from '@playwright/test'; - -// ─── Test constants ────────────────────────────────────────────────────────── +import path from 'path'; -const PORT = 18002; -const BASE_URL = `http://localhost:${PORT}`; -const TMP_DIR = path.join(os.tmpdir(), 'igloo-smoke-test'); +const REQUESTED_PORT_RAW = process.env.SMOKE_TEST_PORT ?? '18002'; +const REQUESTED_PORT = Number.parseInt(REQUESTED_PORT_RAW, 10); +const DEFAULT_PORT = Number.isFinite(REQUESTED_PORT) && REQUESTED_PORT > 0 ? REQUESTED_PORT : 18002; +const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +const TMP_DIR = process.env.SMOKE_TEST_TMP_DIR ?? path.join(os.tmpdir(), `igloo-smoke-test-${RUN_ID}`); const STATE_FILE = path.join(TMP_DIR, 'state.json'); const DB_PATH = path.join(TMP_DIR, 'db'); const SERVER_LOG = path.join(TMP_DIR, 'server.log'); @@ -38,12 +39,75 @@ const ADMIN_SECRET = 'SmokeTestAdmin1'; const ADMIN_USERNAME = 'testadmin'; const ADMIN_PASSWORD = 'T3stPass@9'; -// ─── Helpers ───────────────────────────────────────────────────────────────── +type SetupState = { + port: number; + baseUrl: string; + tmpDir: string; + serverPid: number; + cosignerPid: number; + sessionId: string; + apiKey: string | null; + apiKeyId: string | null; + groupCredential: string; + shareCredentials: string[]; + groupPubkeyHex: string; + adminUsername: string; + adminPassword: string; + adminSecret: string; +}; function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } +function writeState(state: SetupState) { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + process.env.SMOKE_STATE_FILE = STATE_FILE; +} + +function terminateProcess(proc: ChildProcess | null, label: string) { + if (!proc?.pid) return; + try { + process.kill(proc.pid, 'SIGTERM'); + console.log(`[setup] Sent SIGTERM to ${label} (pid ${proc.pid})`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { + console.warn(`[setup] Could not stop ${label} (pid ${proc.pid})`, err); + } + } +} + +function canBindPort(port: number, host: string): Promise { + return new Promise(resolve => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.listen(port, host, () => { + srv.close(() => resolve(true)); + }); + }); +} + +function reserveRandomPort(host: string): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once('error', reject); + srv.listen(0, host, () => { + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + srv.close(() => resolve(port)); + }); + }); +} + +async function resolvePort(host: string, preferredPort: number): Promise { + if (await canBindPort(preferredPort, host)) { + return preferredPort; + } + const fallbackPort = await reserveRandomPort(host); + console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort}`); + return fallbackPort; +} + async function pollUntil( fn: () => Promise, timeoutMs: number, @@ -55,7 +119,7 @@ async function pollUntil( try { if (await fn()) return; } catch { - // ignore, keep polling + // ignore and keep polling } await sleep(intervalMs); } @@ -92,193 +156,209 @@ function spawnDetached( return proc; } -// ─── Global setup ──────────────────────────────────────────────────────────── - export default async function globalSetup(_config: FullConfig): Promise { - // Fresh temp directory every run - if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); - fs.mkdirSync(TMP_DIR, { recursive: true }); - fs.mkdirSync(DB_PATH, { recursive: true }); - - // ── 1. Generate FROSTR credentials via igloo-core ────────────────────────── - console.log('[setup] Generating FROSTR credentials…'); - // Dynamic import so TS type-checker doesn't complain about the JS dist path - const { generateKeysetWithSecret, decodeGroup } = await import( - /* @ts-ignore */ - '../../node_modules/@frostr/igloo-core/dist/index.js' - ) as { - generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; - decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; + const host = '127.0.0.1'; + const port = await resolvePort(host, DEFAULT_PORT); + const baseUrl = `http://${host}:${port}`; + + const state: SetupState = { + port, + baseUrl, + tmpDir: TMP_DIR, + serverPid: 0, + cosignerPid: 0, + sessionId: '', + apiKey: null, + apiKeyId: null, + groupCredential: '', + shareCredentials: [], + groupPubkeyHex: '', + adminUsername: ADMIN_USERNAME, + adminPassword: ADMIN_PASSWORD, + adminSecret: ADMIN_SECRET, }; - // Use 2-of-2 (not 2-of-3) so signing always selects the one connected cosigner. - const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); - const group = decodeGroup(groupCredential); - // x-only pubkey (strip 02/03 compression prefix) for NIP-44/NIP-04 tests - const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); - - - // ── 3. Start igloo-server ────────────────────────────────────────────────── - console.log('[setup] Starting igloo-server on port', PORT, '…'); - const serverProcess = spawnDetached( - 'bun', - ['run', 'src/server.ts'], - { - ADMIN_SECRET, - DB_PATH, - HOST_PORT: String(PORT), - HOST_NAME: '127.0.0.1', - RATE_LIMIT_ENABLED: 'false', - SKIP_RELAY_PROBE: 'true', - SKIP_STARTUP_ECHO: 'true', - NODE_ENV: 'test', - AUTH_ENABLED: 'true', - FROSTR_SIGN_TIMEOUT: '15000', - UI_EVENT_LOG_INCLUDE_PINGS: 'false', - UPDATE_CHECK_DISABLED: 'true', - ALLOW_LOCALHOST_RELAY: 'true', - // Clear any .env credentials so the server starts without pre-loaded creds - GROUP_CRED: '', - SHARE_CRED: '', - RELAYS: '', - }, - SERVER_LOG, - ); + let api: APIRequestContext | null = null; + let serverProcess: ChildProcess | null = null; + let cosignerProcess: ChildProcess | null = null; - await waitForHttp(`${BASE_URL}/api/onboarding/status`, 20_000); - console.log('[setup] Server is up.'); + process.env.SMOKE_STATE_FILE = STATE_FILE; - // ── 4. Onboarding ────────────────────────────────────────────────────────── - console.log('[setup] Running onboarding…'); - const api = await request.newContext({ baseURL: BASE_URL }); + try { + if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(TMP_DIR, { recursive: true }); + fs.mkdirSync(DB_PATH, { recursive: true }); + writeState(state); - let res = await api.post('/api/onboarding/validate-admin', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - }); - if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); + console.log('[setup] Generating FROSTR credentials...'); + const { generateKeysetWithSecret, decodeGroup } = await import( + // @ts-ignore + '../../node_modules/@frostr/igloo-core/dist/index.js' + ) as { + generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; + decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; + }; - res = await api.post('/api/onboarding/setup', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); + const group = decodeGroup(groupCredential); + const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); + state.groupCredential = groupCredential; + state.shareCredentials = shareCredentials; + state.groupPubkeyHex = groupPubkeyHex; + writeState(state); - // ── 5. Login ─────────────────────────────────────────────────────────────── - console.log('[setup] Logging in…'); - res = await api.post('/api/auth/login', { - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); - const { sessionId } = (await res.json()) as { sessionId: string }; - - // ── 6. Set FROSTR credentials (share-0 + group) ──────────────────────────── - // In DB mode credentials are stored per-user (encrypted) via /api/user/credentials, - // NOT via /api/env (which only writes to .env file and does not start the node). - console.log('[setup] Setting FROSTR credentials on server…'); - res = await api.post('/api/user/credentials', { - headers: { 'X-Session-ID': sessionId }, - data: { - group_cred: groupCredential, - share_cred: shareCredentials[0], - relays: [`ws://127.0.0.1:${PORT}`], - }, - }); - if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); + console.log('[setup] Starting igloo-server on port', port, '...'); + serverProcess = spawnDetached( + 'bun', + ['run', 'src/server.ts'], + { + ADMIN_SECRET, + DB_PATH, + HOST_PORT: String(port), + HOST_NAME: host, + RATE_LIMIT_ENABLED: 'false', + SKIP_RELAY_PROBE: 'true', + SKIP_STARTUP_ECHO: 'true', + NODE_ENV: 'test', + AUTH_ENABLED: 'true', + FROSTR_SIGN_TIMEOUT: '15000', + UI_EVENT_LOG_INCLUDE_PINGS: 'false', + UPDATE_CHECK_DISABLED: 'true', + ALLOW_LOCALHOST_RELAY: 'true', + // Clear any .env credentials so the server starts without pre-loaded creds + GROUP_CRED: '', + SHARE_CRED: '', + RELAYS: '', + }, + SERVER_LOG, + ); + state.serverPid = serverProcess.pid ?? 0; + writeState(state); - // Wait for the bifrost node to go active - await pollUntil( - async () => { - const s = await api.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); - if (!s.ok()) return false; - const body = (await s.json()) as { nodeActive: boolean }; - return body.nodeActive === true; - }, - 15_000, - 1000, - 'nodeActive = true', - ); - console.log('[setup] Node is active.'); - - // ── 7. Start co-signer (share index 1) using igloo-core directly ────────── - // We use a minimal cosigner.mjs script so we control which share credentials - // are used and avoid igloo-cli's interactive TUI entirely. - // Server holds shareCredentials[0]; co-signer holds shareCredentials[1]. - console.log('[setup] Starting co-signer with shareCredentials[1]…'); - const cosignerProcess = spawnDetached( - 'node', - [ - path.resolve('tests/e2e/cosigner.mjs'), - groupCredential, - shareCredentials[1], - `ws://127.0.0.1:${PORT}`, - ], - {}, - COSIGNER_LOG, - ); - console.log('[setup] Co-signer pid:', cosignerProcess.pid); - - // ── 8. Signing readiness probe ───────────────────────────────────────────── - // Retry signing a known 32-byte hex to confirm threshold is reachable - console.log('[setup] Probing signing (waiting for co-signer to join relay)…'); - const TEST_MSG = 'a'.repeat(64); // 32-byte all-0xAA event id - let signOk = false; - for (let attempt = 1; attempt <= 5; attempt++) { - await sleep(3000); - const sr = await api.post('/api/sign', { + await waitForHttp(`${baseUrl}/api/onboarding/status`, 20_000); + console.log('[setup] Server is up.'); + + console.log('[setup] Running onboarding...'); + api = await request.newContext({ baseURL: baseUrl }); + + let res = await api.post('/api/onboarding/validate-admin', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + }); + if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); + + res = await api.post('/api/onboarding/setup', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); + + console.log('[setup] Logging in...'); + res = await api.post('/api/auth/login', { + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); + const { sessionId } = (await res.json()) as { sessionId: string }; + state.sessionId = sessionId; + writeState(state); + + console.log('[setup] Setting FROSTR credentials on server...'); + res = await api.post('/api/user/credentials', { headers: { 'X-Session-ID': sessionId }, - data: { message: TEST_MSG }, - }).catch(() => null); - if (sr && sr.ok()) { - signOk = true; - console.log(`[setup] Signing OK on attempt ${attempt}.`); - break; + data: { + group_cred: groupCredential, + share_cred: shareCredentials[0], + relays: [`ws://${host}:${port}`], + }, + }); + if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); + + await pollUntil( + async () => { + const s = await api!.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); + if (!s.ok()) return false; + const body = (await s.json()) as { nodeActive: boolean }; + return body.nodeActive === true; + }, + 15_000, + 1000, + 'nodeActive = true', + ); + console.log('[setup] Node is active.'); + + console.log('[setup] Starting co-signer with shareCredentials[1]...'); + cosignerProcess = spawnDetached( + 'node', + [ + path.resolve('tests/e2e/cosigner.mjs'), + groupCredential, + shareCredentials[1], + `ws://${host}:${port}`, + ], + {}, + COSIGNER_LOG, + ); + state.cosignerPid = cosignerProcess.pid ?? 0; + writeState(state); + console.log('[setup] Co-signer pid:', cosignerProcess.pid); + + console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); + const TEST_MSG = 'a'.repeat(64); + let signOk = false; + for (let attempt = 1; attempt <= 5; attempt++) { + await sleep(3000); + const sr = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: TEST_MSG }, + }).catch(() => null); + if (sr && sr.ok()) { + signOk = true; + console.log(`[setup] Signing OK on attempt ${attempt}.`); + break; + } + const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; + console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); + } + if (!signOk) { + const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; + throw new Error(`Co-signer did not become ready within 15 s.\nCo-signer log:\n${cosLog}`); } - const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; - console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); - } - if (!signOk) { - const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error(`Co-signer did not become ready within 25 s.\nCo-signer log:\n${cosLog}`); - } - // ── 9. Create persistent test API key ───────────────────────────────────── - console.log('[setup] Creating test API key…'); - res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'smoke-test-key' }, - }); - let apiKey: string | null = null; - let apiKeyId: string | null = null; - if (res.ok()) { - const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; - apiKey = body.apiKey.token; - apiKeyId = String(body.apiKey.id); - } else { - console.warn('[setup] Could not create API key – admin key tests will be skipped.'); - } + console.log('[setup] Creating test API key...'); + res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'smoke-test-key' }, + }); + if (res.ok()) { + const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; + state.apiKey = body.apiKey.token; + state.apiKeyId = String(body.apiKey.id); + } else { + console.warn('[setup] Could not create API key - auth tests will skip API-key checks.'); + } - await api.dispose(); + await api.dispose(); + api = null; - // ── 10. Persist shared state ─────────────────────────────────────────────── - const state = { - port: PORT, - baseUrl: BASE_URL, - tmpDir: TMP_DIR, - serverPid: serverProcess.pid, - cosignerPid: cosignerProcess.pid, - sessionId, - apiKey, - apiKeyId, - groupCredential, - shareCredentials, - groupPubkeyHex, - adminUsername: ADMIN_USERNAME, - adminPassword: ADMIN_PASSWORD, - adminSecret: ADMIN_SECRET, - }; + writeState(state); + console.log('[setup] Global setup complete. State saved to', STATE_FILE); + } catch (err) { + if (api) { + try { + await api.dispose(); + } catch { + // no-op + } + } - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); - process.env.SMOKE_STATE_FILE = STATE_FILE; + terminateProcess(cosignerProcess, 'co-signer'); + terminateProcess(serverProcess, 'server'); - console.log('[setup] ✓ Global setup complete. State saved to', STATE_FILE); + // Persist whatever we have so teardown can still clean up. + try { + writeState(state); + } catch { + // no-op + } + throw err; + } } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index f4ffd72..d087494 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -4,18 +4,44 @@ */ import fs from 'fs'; +import os from 'os'; +import path from 'path'; import type { FullConfig } from '@playwright/test'; +function findLatestStateFile(): string | null { + const tmpRoot = os.tmpdir(); + let latestFile: string | null = null; + let latestMtime = 0; + + for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; + const candidate = path.join(tmpRoot, entry.name, 'state.json'); + if (!fs.existsSync(candidate)) continue; + const mtime = fs.statSync(candidate).mtimeMs; + if (mtime > latestMtime) { + latestMtime = mtime; + latestFile = candidate; + } + } + + return latestFile; +} + export default async function globalTeardown(_config: FullConfig): Promise { const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile || !fs.existsSync(stateFile)) { + const resolvedStateFile = + stateFile && fs.existsSync(stateFile) + ? stateFile + : findLatestStateFile(); + + if (!resolvedStateFile || !fs.existsSync(resolvedStateFile)) { console.warn('[teardown] No state file found – nothing to clean up.'); return; } let state: { serverPid?: number; cosignerPid?: number; tmpDir?: string }; try { - state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')); } catch { console.warn('[teardown] Could not parse state file.'); return; @@ -37,10 +63,11 @@ export default async function globalTeardown(_config: FullConfig): Promise // Brief pause to let processes flush logs await new Promise(r => setTimeout(r, 500)); - if (state.tmpDir) { + const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); + if (tmpDir) { try { - fs.rmSync(state.tmpDir, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', state.tmpDir); + fs.rmSync(tmpDir, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', tmpDir); } catch (err) { console.warn('[teardown] Could not remove temp dir:', err); } diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index 91fba5c..4ac2a8e 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -65,20 +65,20 @@ test.describe('Auth – /api/auth', () => { await api.dispose(); }); - test('GET /api/status – valid API key (X-API-Key) returns 200', async () => { + test('GET /api/event-log – valid API key (X-API-Key) returns 200', async () => { test.skip(!apiKey, 'No API key available'); const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { + const res = await api.get('/api/event-log', { headers: { 'X-API-Key': apiKey! }, }); expect(res.status()).toBe(200); await api.dispose(); }); - test('GET /api/status – valid API key (Bearer) returns 200', async () => { + test('GET /api/event-log – valid API key (Bearer) returns 200', async () => { test.skip(!apiKey, 'No API key available'); const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { + const res = await api.get('/api/event-log', { headers: { Authorization: `Bearer ${apiKey!}` }, }); expect(res.status()).toBe(200); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 470be6e..4c14af2 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -51,8 +51,8 @@ test.describe('Admin – API keys', () => { }); const { apiKey } = await createRes.json(); - // Use key to hit a protected route - const authRes = await api.get('/api/status', { + // Use key to hit an auth-protected route (status is intentionally public). + const authRes = await api.get('/api/event-log', { headers: { 'X-API-Key': apiKey.token }, }); expect(authRes.status()).toBe(200); From d3c38a21197dee638dcbd8f99eb8d3d6079b2ed0 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 22:06:55 -0600 Subject: [PATCH 04/32] fix: move env admin gate before validation, fix Bearer token parsing, and IPv6 loopback filter --- .github/workflows/ci.yml | 2 +- .gitignore | 1 + llm/implementation/e2e-smoke-tests.md | 10 ++-- llm/implementation/umbrel-implementation.md | 2 +- scripts/release.sh | 6 +-- src/routes/env.ts | 53 +++++++++++------- src/routes/utils.test.ts | 24 ++++++++- src/routes/utils.ts | 13 +++-- test-results/.last-run.json | 4 -- tests/e2e/cosigner.mjs | 5 +- tests/e2e/global-setup.ts | 59 ++++++++++++++------- tests/e2e/global-teardown.ts | 17 +++++- tests/e2e/helpers.ts | 16 ++++++ tests/e2e/specs/01-auth.e2e.ts | 1 + tests/e2e/specs/02-status-peers.e2e.ts | 1 + tests/e2e/specs/03-nip44-nip04.e2e.ts | 10 ++++ tests/e2e/specs/06-event-log.e2e.ts | 1 + tests/e2e/specs/07-env.e2e.ts | 14 +++++ tests/e2e/specs/08-ui.e2e.ts | 26 ++------- tests/e2e/state.ts | 37 +++++++++++-- tests/routes/env.db-mode.spec.ts | 3 +- 21 files changed, 217 insertions(+), 88 deletions(-) delete mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/helpers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4bfef2..732d97c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,7 @@ jobs: exit 1 - name: Check for secrets - uses: trufflesecurity/trufflehog@main + uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ extra_args: --debug --only-verified diff --git a/.gitignore b/.gitignore index bf245db..2817c75 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ data/.session-secret test-*.sh debug-*.js verify-*.md +test-results/ .DS_Store # LLM files diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index 5c40865..6ecd3a8 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -32,11 +32,11 @@ npx playwright show-report Prerequisites: - `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). - `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). -- No other process listening on port 18002. +- Keep port 18002 free when possible. `tests/e2e/global-setup.ts` calls `resolvePort()` and will usually fall back to a random free port if 18002 is busy, but hard-coded references can still break if the preferred port is unavailable. ## File Structure -``` +```text tests/e2e/ ├── global-setup.ts # Starts server + co-signer, completes onboarding, writes state.json ├── global-teardown.ts # SIGTERMs both processes, deletes temp dir @@ -90,7 +90,7 @@ The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, l ### 3. Complete onboarding -``` +```text POST /api/onboarding/validate-admin (Bearer ADMIN_SECRET) POST /api/onboarding/setup (creates admin user with username + password) POST /api/auth/login → sessionId @@ -98,7 +98,7 @@ POST /api/auth/login → sessionId ### 4. Set FROSTR credentials -``` +```text POST /api/user/credentials { group_cred, share_cred: shareCredentials[0], relays: ['ws://127.0.0.1:18002'] } ``` @@ -331,7 +331,7 @@ Without this fix, the server's relay would reject all subscriptions from the bif Each run creates a fresh temp directory at `$TMPDIR/igloo-smoke-test/` (deleted by teardown): -``` +```text igloo-smoke-test/ ├── db/ # SQLite database files (igloo.db, .session-secret) ├── state.json # Shared test state (pids, session, credentials, etc.) diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 22353b5..a3e7955 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -71,6 +71,6 @@ These values are set in the store compose and expected by the UI flow: ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update the digest in `igloo-server-store/igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). +2. Update the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). 3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/scripts/release.sh b/scripts/release.sh index e161490..c2c57b0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -73,13 +73,9 @@ for i in {1..5}; do fi done -if [ "$SERVER_HEALTHY" = false ]; then - echo "❌ Server failed to respond after 5 attempts" -fi - # Cleanup will be handled by trap, just check if we should fail if [ "$SERVER_HEALTHY" = false ]; then - echo "❌ Server startup test failed - cannot proceed with release" + echo "❌ Server failed to respond after 5 attempts - cannot proceed with release" exit 1 fi diff --git a/src/routes/env.ts b/src/routes/env.ts index d376827..3bc16e0 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -276,6 +276,21 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged const env = await readEnvFile(); const { validKeys, invalidKeys: rejectedKeys } = validateEnvKeys(Object.keys(body)); + // DB mode privilege gate for env writes (no legacy fallback): + // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or + // - allow when the authenticated DB user has role=admin. + // validateAdminSecret() returns false when the header is missing; there is no bypass. + const authHeader = req.headers.get('Authorization'); + const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; + const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; + const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); + if (!isAdminSecret && !isRoleAdmin) { + return Response.json( + { error: 'Admin privileges required for environment modifications' }, + { status: 403, headers } + ); + } + if (validKeys.includes('RELAYS') && body.RELAYS !== undefined) { const relayValidation = validateRelayUrls(body.RELAYS); if (!relayValidation.valid) { @@ -283,33 +298,20 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged } } - if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED) { + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { const groupValidation = validateGroup(body.GROUP_CRED); if (!groupValidation.isValid) { return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); } } - if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED) { + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED !== undefined) { const shareValidation = validateShare(body.SHARE_CRED); if (!shareValidation.isValid) { return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); } } - // DB mode privilege gate for env writes (no legacy fallback): - // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or - // - allow when the authenticated DB user has role=admin. - // validateAdminSecret() returns false when the header is missing; there is no bypass. - const adminSecret = req.headers.get('X-Admin-Secret') ?? req.headers.get('Authorization')?.replace(/^Bearer\s+/i, ''); - const isAdminSecret = await validateAdminSecret(adminSecret); - if (!isAdminSecret && !isRoleAdmin) { - return Response.json( - { error: 'Admin privileges required for environment modifications' }, - { status: 403, headers } - ); - } - for (const key of validKeys) { if (body[key] !== undefined) { env[key] = body[key]; @@ -364,6 +366,20 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged } } + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { + const groupValidation = validateGroup(body.GROUP_CRED); + if (!groupValidation.isValid) { + return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); + } + } + + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED !== undefined) { + const shareValidation = validateShare(body.SHARE_CRED); + if (!shareValidation.isValid) { + return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); + } + } + for (const key of validKeys) { if (body[key] !== undefined) { env[key] = body[key]; @@ -643,9 +659,10 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged ); } if (!HEADLESS) { - const adminSecret = req.headers.get('X-Admin-Secret') ?? - req.headers.get('Authorization')?.replace(/^Bearer\s+/i, ''); - const isAdminSecret = await validateAdminSecret(adminSecret); + const authHeader = req.headers.get('Authorization'); + const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; + const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; + const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); if (!isAdminSecret && !isRoleAdmin) { return Response.json( { error: 'Admin privileges required for deleting environment variables' }, diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 6236e1e..53f1d71 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { getValidRelays } from './utils.js'; +import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; describe('getValidRelays', () => { it('returns default relay when fallback is enabled and input is empty', () => { @@ -20,4 +20,26 @@ describe('getValidRelays', () => { it('filters invalid relays and returns empty when fallback disabled', () => { expect(getValidRelays('["not-a-relay","ftp://example.com"]', { fallbackToDefault: false })).toEqual([]); }); + + it('filters IPv6 localhost relay when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); + + it('keeps localhost relay in echo list when explicitly allowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'true'; + try { + expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index b1e31b4..8d49880 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -32,6 +32,11 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { return hex.toLowerCase(); } +function isLoopbackRelayHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[(.*)\]$/, '$1'); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + // Helper function to get valid relay URLs export function getValidRelays( envRelays?: string, @@ -66,7 +71,7 @@ export function getValidRelays( const url = new URL(relay); // Exclude localhost relays to avoid conflicts with our server // (unless explicitly allowed, e.g. for testing) - if (!allowLocalhost && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) { + if (!allowLocalhost && isLoopbackRelayHost(url.hostname)) { console.warn(`Excluding localhost relay to avoid conflicts: ${relay}`); return false; } @@ -797,14 +802,16 @@ export function validateRelayUrls(relays: any): { valid: boolean; urls?: string[ export function normalizeRelayListForEcho(relays: any): string[] | undefined { const validation = validateRelayUrls(relays); if (!validation.valid || !validation.urls || validation.urls.length === 0) return undefined; + const allowLocalhost = process.env['ALLOW_LOCALHOST_RELAY'] === 'true'; const filtered = validation.urls .map((r) => r.trim()) .filter((r) => r.length > 0) .filter((r) => { try { const u = new URL(r); - return (u.protocol === 'ws:' || u.protocol === 'wss:') && - u.hostname !== 'localhost' && u.hostname !== '127.0.0.1' && u.hostname !== '::1'; + if (u.protocol !== 'ws:' && u.protocol !== 'wss:') return false; + if (!allowLocalhost && isLoopbackRelayHost(u.hostname)) return false; + return true; } catch { return false; } diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index cbcc1fb..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 2c65947..100d88c 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -14,7 +14,7 @@ if (!groupCred || !shareCred || !relayUrl) { const { createBifrostNode, connectNode, -} = await import('../../node_modules/@frostr/igloo-core/dist/index.js'); +} = await import('@frostr/igloo-core'); let node; try { @@ -44,7 +44,8 @@ try { console.log('[cosigner] Connecting to relay:', relayUrl); await connectNode(node); console.log('[cosigner] Connected. Pubkey:', node.pubkey); - console.log('[cosigner] Filter:', JSON.stringify(node.client?._filter ?? node.client?.filter ?? '?')); + // `_filter` is a private fallback for older client internals. + console.log('[cosigner] Filter:', JSON.stringify(node.client?.filter ?? node.client?._filter ?? '?')); } catch (err) { console.error('[cosigner] Failed to start:', err.message ?? err); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index bbc1534..33e9c59 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -31,13 +31,11 @@ const DB_PATH = path.join(TMP_DIR, 'db'); const SERVER_LOG = path.join(TMP_DIR, 'server.log'); const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); -// A fixed, deterministic 32-byte secp256k1 private key (well below curve order) -const TEST_NSEC_HEX = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - -// Meets igloo-server password rules: upper + lower + digit + special(@), no sequences -const ADMIN_SECRET = 'SmokeTestAdmin1'; -const ADMIN_USERNAME = 'testadmin'; -const ADMIN_PASSWORD = 'T3stPass@9'; +// Defaults are safe for local CI, but callers should override via environment variables. +const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const ADMIN_SECRET = process.env.ADMIN_SECRET ?? 'SmokeTestAdmin1'; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? 'testadmin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'T3stPass@9'; type SetupState = { port: number; @@ -77,6 +75,8 @@ function terminateProcess(proc: ChildProcess | null, label: string) { } } +// Port probing is inherently TOCTOU: we can only test availability now, not reserve it +// forever. For CI smoke tests this low-probability race is acceptable. function canBindPort(port: number, host: string): Promise { return new Promise(resolve => { const srv = net.createServer(); @@ -87,6 +87,8 @@ function canBindPort(port: number, host: string): Promise { }); } +// Reserve an ephemeral port by binding to :0 and immediately closing; another process +// could still claim it before spawn, but this is sufficient for smoke test setup. function reserveRandomPort(host: string): Promise { return new Promise((resolve, reject) => { const srv = net.createServer(); @@ -99,12 +101,14 @@ function reserveRandomPort(host: string): Promise { }); } +// Prefer the requested port, but fall back when busy. This does not eliminate the +// bind race between probing and process startup. async function resolvePort(host: string, preferredPort: number): Promise { if (await canBindPort(preferredPort, host)) { return preferredPort; } const fallbackPort = await reserveRandomPort(host); - console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort}`); + console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort} (probe-close race still applies)`); return fallbackPort; } @@ -115,11 +119,18 @@ async function pollUntil( label = 'condition', ): Promise { const deadline = Date.now() + timeoutMs; + let consecutiveFailures = 0; while (Date.now() < deadline) { try { if (await fn()) return; - } catch { - // ignore and keep polling + consecutiveFailures = 0; + } catch (error) { + consecutiveFailures += 1; + const nearingDeadline = Date.now() + intervalMs >= deadline; + if (consecutiveFailures === 3 || nearingDeadline) { + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.warn(`[setup] pollUntil(${label}) transient failure x${consecutiveFailures}: ${detail}`); + } } await sleep(intervalMs); } @@ -145,15 +156,19 @@ function spawnDetached( logFile: string, ): ChildProcess { const out = fs.openSync(logFile, 'a'); - const proc = spawn(cmd, args, { - env: { ...process.env, ...env }, - detached: false, - stdio: ['ignore', out, out], - }); - proc.on('error', err => { - fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); - }); - return proc; + try { + const proc = spawn(cmd, args, { + env: { ...process.env, ...env }, + detached: false, + stdio: ['ignore', out, out], + }); + proc.on('error', err => { + fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); + }); + return proc; + } finally { + fs.closeSync(out); + } } export default async function globalSetup(_config: FullConfig): Promise { @@ -305,6 +320,12 @@ export default async function globalSetup(_config: FullConfig): Promise { const TEST_MSG = 'a'.repeat(64); let signOk = false; for (let attempt = 1; attempt <= 5; attempt++) { + if (cosignerProcess && cosignerProcess.exitCode !== null) { + const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; + throw new Error( + `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` + ); + } await sleep(3000); const sr = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index d087494..0dbb322 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -66,8 +66,21 @@ export default async function globalTeardown(_config: FullConfig): Promise const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); if (tmpDir) { try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', tmpDir); + const resolvedTmp = path.resolve(tmpDir); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); + + if (!isInsideTemp) { + console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); + } else { + fs.rmSync(resolvedTmp, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', resolvedTmp); + } } catch (err) { console.warn('[teardown] Could not remove temp dir:', err); } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..37f6e99 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,16 @@ +import type { Page } from '@playwright/test'; + +export async function loginAs(page: Page, username: string, password: string): Promise { + const usernameField = page + .locator('input[type="text"], input[id*="user"], input[name*="user"], input[name*="ur"]') + .first(); + const passwordField = page.locator('input[type="password"]').first(); + const submitBtn = page + .locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")') + .first(); + + await usernameField.fill(username); + await passwordField.fill(password); + await submitBtn.click(); + await page.waitForLoadState('networkidle'); +} diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index 4ac2a8e..e37a56b 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -100,6 +100,7 @@ test.describe('Auth – /api/auth', () => { const loginRes = await api.post('/api/auth/login', { data: { username: adminUsername, password: adminPassword }, }); + expect(loginRes.status()).toBe(200); const { sessionId: tempSession } = await loginRes.json(); const logoutRes = await api.post('/api/auth/logout', { diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index 2190c8c..0066a79 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -38,6 +38,7 @@ test.describe('Status – /api/status', () => { const res = await api.get('/api/status', { headers: { 'X-Session-ID': sessionId }, }); + expect(res.status()).toBe(200); const body = await res.json(); expect(body.health).toHaveProperty('isConnected'); expect(typeof body.health.isConnected).toBe('boolean'); diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts index cd118f4..60b3353 100644 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -137,4 +137,14 @@ test.describe('NIP-04 – /api/nip04', () => { expect(res.status()).toBe(400); await api.dispose(); }); + + test('missing content returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 4c85213..50d7ec0 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -34,6 +34,7 @@ test.describe('Event log – /api/event-log', () => { const res = await api.get('/api/event-log', { headers: { 'X-Session-ID': sessionId }, }); + expect(res.status()).toBe(200); const body = await res.json(); // If there are entries from sign tests, validate their shape diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts index 0ced112..61017e3 100644 --- a/tests/e2e/specs/07-env.e2e.ts +++ b/tests/e2e/specs/07-env.e2e.ts @@ -74,6 +74,20 @@ test.describe('Env / credentials – /api/env', () => { await api.dispose(); }); + test('POST /api/env – empty RELAYS returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: [], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + test('POST /api/env without auth returns 401', async () => { const api = await request.newContext({ baseURL: baseUrl }); const res = await api.post('/api/env', { diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 8c38bc5..d73ebfe 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -6,6 +6,7 @@ */ import { test, expect } from '@playwright/test'; +import { loginAs } from '../helpers.js'; import { loadState } from '../state.js'; const state = loadState(); @@ -16,7 +17,7 @@ test.describe('UI – Login page', () => { await page.goto(baseUrl); // The SPA should show either the login form or onboarding // Since onboarding is complete, we expect the login form - await expect(page).toHaveURL(baseUrl + '/'); + await expect(page).toHaveURL(new URL('/', baseUrl).toString()); // Login form has username + password inputs await expect(page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first()).toBeVisible({ timeout: 10_000, @@ -26,17 +27,7 @@ test.describe('UI – Login page', () => { test('login form accepts credentials and navigates to dashboard', async ({ page }) => { await page.goto(baseUrl); - - // Fill in the login form - const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); - const passwordField = page.locator('input[type="password"]').first(); - - await usernameField.fill(adminUsername); - await passwordField.fill(adminPassword); - - // Submit (button with type=submit or labeled "Login"/"Sign in") - const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); - await submitBtn.click(); + await loginAs(page, adminUsername, adminPassword); // After login we should see the main app tabs await expect(page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first()).toBeVisible({ @@ -49,16 +40,7 @@ test.describe('UI – Authenticated app', () => { // Log in once per test block using page fixtures (each test gets a fresh page) test.beforeEach(async ({ page }) => { await page.goto(baseUrl); - - const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); - const passwordField = page.locator('input[type="password"]').first(); - await usernameField.fill(adminUsername); - await passwordField.fill(adminPassword); - const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); - await submitBtn.click(); - - // Wait for the app to load - await page.waitForLoadState('networkidle'); + await loginAs(page, adminUsername, adminPassword); }); test('Signer tab is visible and shows node status indicator', async ({ page }) => { diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index 76616f5..c7d9206 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { z } from 'zod'; export interface SmokeTestState { port: number; @@ -17,6 +18,27 @@ export interface SmokeTestState { adminSecret: string; } +/** + * Zod schema for SmokeTestState persisted by global-setup. + * Validates shape and types before returning from loadState. + */ +const smokeTestStateSchema = z.object({ + port: z.number().int().positive(), + baseUrl: z.string().min(1, 'baseUrl must be non-empty'), + tmpDir: z.string(), + serverPid: z.number().int().nonnegative(), + cosignerPid: z.number().int().nonnegative(), + sessionId: z.string().min(1, 'sessionId must be non-empty'), + apiKey: z.string().nullable(), + apiKeyId: z.string().nullable(), + groupCredential: z.string(), + shareCredentials: z.array(z.string()), + groupPubkeyHex: z.string(), + adminUsername: z.string(), + adminPassword: z.string(), + adminSecret: z.string(), +}); + const STUB: SmokeTestState = { port: 18002, baseUrl: 'http://localhost:18002', @@ -44,8 +66,17 @@ export function loadState(): SmokeTestState { const stateFile = process.env.SMOKE_STATE_FILE; if (!stateFile) return STUB; try { - return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SmokeTestState; - } catch { - return STUB; + const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + const result = smokeTestStateSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues + .map(i => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`validation failed: ${issues}`); + } + return result.data as SmokeTestState; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid smoke test state in ${stateFile}: ${detail}`); } } diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 91d1c2c..67abb95 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -104,7 +104,6 @@ describe('DB-mode /api/env behavior', () => { const out = runRouteScript(script); expect(out.hasStamp).toBeTrue(); - // Status may be 200 on success or 500 if restart failed; accept either - expect([200, 500]).toContain(out.status); + expect(out.status).toBe(200); }, { timeout: 10000 }); }); From 3a6cb0bf75ff1a15ff8ea6bcca48dad29a2ed4c2 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 09:42:51 -0600 Subject: [PATCH 05/32] fix: reject empty RELAYS in /api/env updates --- src/routes/env.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/routes/env.ts b/src/routes/env.ts index 3bc16e0..d204929 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -296,6 +296,9 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (!relayValidation.valid) { return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); } + if (!relayValidation.urls || relayValidation.urls.length === 0) { + return Response.json({ success: false, error: 'At least one relay URL is required' }, { status: 400, headers }); + } } if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { @@ -364,6 +367,9 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (!relayValidation.valid) { return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); } + if (!relayValidation.urls || relayValidation.urls.length === 0) { + return Response.json({ success: false, error: 'At least one relay URL is required' }, { status: 400, headers }); + } } if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { From 156ce53500ffd0741aada6eee8d2379655ce9426 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 11:26:03 -0600 Subject: [PATCH 06/32] fix: expand 127.0.0.0/8 loopback filter, harden E2E cosigner, and clean up imports --- .github/workflows/ci.yml | 2 +- .gitignore | 1 - frontend/components/ui/peer-list.tsx | 1 + llm/implementation/e2e-smoke-tests.md | 4 +-- package.json | 5 ++-- playwright.config.ts | 1 - src/routes/utils.test.ts | 13 ++++++++++ src/routes/utils.ts | 6 ++++- tests/e2e/cosigner.mjs | 35 ++++++++++++++++++++++++-- tests/e2e/global-setup.ts | 5 +--- tests/e2e/global-teardown.ts | 1 + tests/e2e/helpers.ts | 2 +- tests/e2e/specs/02-status-peers.e2e.ts | 2 +- tests/e2e/specs/04-sign.e2e.ts | 1 + tests/e2e/specs/05-admin.e2e.ts | 1 + tests/e2e/specs/06-event-log.e2e.ts | 14 +++++------ tests/e2e/state.ts | 2 +- tests/routes/helpers/script-runner.ts | 7 +++++- 18 files changed, 77 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 732d97c..df1c441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: bun run tsc --noEmit - name: Run route tests - run: bun test tests/routes + run: bun run test:unit - name: Build frontend run: bun run build diff --git a/.gitignore b/.gitignore index 2817c75..eda8834 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,6 @@ test-*.sh debug-*.js verify-*.md test-results/ -.DS_Store # LLM files .claude diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 96adf38..4f65b5c 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -597,6 +597,7 @@ const PeerList: React.FC = ({ className="flex flex-col sm:flex-row sm:items-center justify-between bg-gray-800/50 p-2.5 rounded cursor-pointer hover:bg-gray-800/70 transition-colors gap-2 sm:gap-0" onClick={handleToggle} role="button" + aria-expanded={isExpanded} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index 6ecd3a8..ca6ff00 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -192,8 +192,8 @@ Uses `state.groupPubkeyHex` as the peer pubkey for encryption (the server encryp - 400 for non-hex message - 400 for message shorter than 32 bytes - 400 for missing body -- Signs a 32-byte hex message; response contains `sig` and `pubkey` -- Signs a full Nostr event object (with `id`, `content`, `kind`, `created_at`, `tags`) +- Signs a 32-byte hex message; response contains `id` and `signature` +- Signs a full Nostr event object (with `id`, `pubkey`, `content`, `kind`, `created_at`, `tags`) - Signs with API key auth (`X-API-Key` header) — confirms DB-backed API keys work for signing - 400 for event with invalid pubkey diff --git a/package.json b/package.json index 02cdb89..4c98c50 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:unit": "bun test --max-concurrency=1 src tests/routes", "test:e2e:smoke": "npx playwright test --project=api tests/e2e/specs/01-auth.e2e.ts tests/e2e/specs/04-sign.e2e.ts tests/e2e/specs/05-admin.e2e.ts", "test:e2e": "npx playwright test", - "test:e2e:nightly": "npx playwright test", + "test:e2e:nightly": "npx playwright test --project=api --project=ui --retries=2 --timeout=60000", "test:e2e:ui": "npx playwright test --project=ui", "test:e2e:api": "npx playwright test --project=api", "test:e2e:report": "npx playwright show-report", @@ -62,7 +62,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^3.3.1", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "zod": "^3.25.76" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/playwright.config.ts b/playwright.config.ts index 384184a..42fdb5b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,7 +18,6 @@ export default defineConfig({ ], use: { - baseURL: 'http://localhost:18002', trace: 'on-first-retry', // Longer default for operations that wait on bifrost relay round-trips actionTimeout: 15_000, diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 53f1d71..629ae27 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -31,7 +31,20 @@ describe('getValidRelays', () => { else process.env.ALLOW_LOCALHOST_RELAY = previous; } }); + + it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); +}); +describe('normalizeRelayListForEcho', () => { it('keeps localhost relay in echo list when explicitly allowed', () => { const previous = process.env.ALLOW_LOCALHOST_RELAY; process.env.ALLOW_LOCALHOST_RELAY = 'true'; diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 8d49880..c9086fe 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -34,7 +34,11 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { function isLoopbackRelayHost(hostname: string): boolean { const normalized = hostname.replace(/^\[(.*)\]$/, '$1'); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; + if (normalized === 'localhost' || normalized === '::1') return true; + const octets = normalized.split('.'); + if (octets.length !== 4) return false; + if (octets[0] !== '127') return false; + return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); } // Helper function to get valid relay URLs diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 100d88c..a598fbb 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -16,6 +16,28 @@ const { connectNode, } = await import('@frostr/igloo-core'); +/** + * Serializes a value to JSON, replacing circular refs with "[Circular]" to avoid + * "Converting circular structure to JSON" TypeError from bubbling into outer catch. + * @param {unknown} obj - Value to serialize + * @returns {string} JSON string or fallback representation + */ +function safeStringify(obj) { + const seen = new WeakSet(); + function replacer(_key, value) { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + } + return value; + } + try { + return JSON.stringify(obj, replacer); + } catch (e) { + return '[Non-serializable]'; + } +} + let node; try { node = createBifrostNode({ @@ -44,8 +66,17 @@ try { console.log('[cosigner] Connecting to relay:', relayUrl); await connectNode(node); console.log('[cosigner] Connected. Pubkey:', node.pubkey); - // `_filter` is a private fallback for older client internals. - console.log('[cosigner] Filter:', JSON.stringify(node.client?.filter ?? node.client?._filter ?? '?')); + const filter = node.client?.filter; + const privateFilter = node.client?._filter; + if (filter !== undefined) { + console.log('[cosigner] Filter (public):', safeStringify(filter)); + } else if (privateFilter !== undefined) { + // TODO: Remove private fallback once @frostr/igloo-core exposes a stable public filter accessor. + console.warn('[cosigner] Filter fallback in use: node.client._filter (private internals)'); + console.log('[cosigner] Filter (private fallback):', safeStringify(privateFilter)); + } else { + console.warn('[cosigner] Filter unavailable on node.client (public and private fields missing)'); + } } catch (err) { console.error('[cosigner] Failed to start:', err.message ?? err); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 33e9c59..ade0314 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -206,10 +206,7 @@ export default async function globalSetup(_config: FullConfig): Promise { writeState(state); console.log('[setup] Generating FROSTR credentials...'); - const { generateKeysetWithSecret, decodeGroup } = await import( - // @ts-ignore - '../../node_modules/@frostr/igloo-core/dist/index.js' - ) as { + const { generateKeysetWithSecret, decodeGroup } = await import('@frostr/igloo-core') as { generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; }; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 0dbb322..64e26f1 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -33,6 +33,7 @@ export default async function globalTeardown(_config: FullConfig): Promise stateFile && fs.existsSync(stateFile) ? stateFile : findLatestStateFile(); + console.log('[teardown] Resolved state file:', resolvedStateFile ?? '(none)'); if (!resolvedStateFile || !fs.existsSync(resolvedStateFile)) { console.warn('[teardown] No state file found – nothing to clean up.'); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 37f6e99..a4cd656 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'; export async function loginAs(page: Page, username: string, password: string): Promise { const usernameField = page - .locator('input[type="text"], input[id*="user"], input[name*="user"], input[name*="ur"]') + .locator('input[type="text"], input[id*="user"], input[name*="user"]') .first(); const passwordField = page.locator('input[type="password"]').first(); const submitBtn = page diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index 0066a79..d5b4e2b 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -65,7 +65,7 @@ test.describe('Peers – /api/peers', () => { const body = await res.json(); expect(body).toHaveProperty('peers'); expect(Array.isArray(body.peers)).toBe(true); - // 2-of-3 keyset: 2 remote peers (self filtered out) + // 2-of-2 keyset: 1 remote peer (self filtered out) expect(body.peers.length).toBeGreaterThanOrEqual(1); expect(typeof body.total).toBe('number'); expect(typeof body.online).toBe('number'); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 8e8d943..65d3d72 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -16,6 +16,7 @@ const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; test.describe('Sign – /api/sign', () => { + // Explicit per-suite timeout for signing flows; global timeout is also 30_000. test.setTimeout(30_000); test('returns 401 without auth', async () => { diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 4c14af2..e3cff89 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -49,6 +49,7 @@ test.describe('Admin – API keys', () => { headers: { 'X-Session-ID': sessionId }, data: { label: 'auth-test-key' }, }); + expect(createRes.status()).toBe(201); const { apiKey } = await createRes.json(); // Use key to hit an auth-protected route (status is intentionally public). diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 50d7ec0..3bcda19 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -36,14 +36,12 @@ test.describe('Event log – /api/event-log', () => { }); expect(res.status()).toBe(200); const body = await res.json(); - - // If there are entries from sign tests, validate their shape - if (body.entries.length > 0) { - const entry = body.entries[0]; - expect(entry).toHaveProperty('type'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('timestamp'); - } + expect(Array.isArray(body.entries)).toBe(true); + expect(body.entries.length).toBeGreaterThan(0); + const entry = body.entries[0]; + expect(entry).toHaveProperty('type'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('timestamp'); await api.dispose(); }); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index c7d9206..033695a 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -74,7 +74,7 @@ export function loadState(): SmokeTestState { .join('; '); throw new Error(`validation failed: ${issues}`); } - return result.data as SmokeTestState; + return result.data; } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Invalid smoke test state in ${stateFile}: ${detail}`); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 87db273..e1f2aa7 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -32,7 +32,12 @@ const ISOLATED_ENV_PREFIXES = [ ]; function buildScriptEnv(overrides: Record): Record { - const nextEnv: Record = { ...process.env } as Record; + const nextEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') { + nextEnv[key] = value; + } + } for (const key of ISOLATED_ENV_KEYS) { delete nextEnv[key]; From 33e14cdc2bf8f3c01a8194f5b4daa8a15dbb0d7d Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 12:33:40 -0600 Subject: [PATCH 07/32] fix: lazy ENV_FILE_PATH eval, peer-list hook deps/a11y, CI audit output, and E2E scaffolding --- .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 2 +- frontend/components/ui/peer-list.tsx | 20 +- llm/implementation/e2e-smoke-tests.md | 2 +- .../node-lifecycle-implementation.md | 2 +- src/routes/utils.test.ts | 22 ++ src/routes/utils.ts | 15 +- tests/e2e/cosigner.mjs | 6 +- tests/e2e/global-setup.ts | 47 +++-- tests/e2e/global-teardown.ts | 64 +++--- tests/e2e/helpers.ts | 5 + tests/e2e/smoke-test-defaults.json | 6 + tests/e2e/specs/04-sign.e2e.ts | 28 ++- tests/e2e/specs/05-admin.e2e.ts | 194 +++++++++--------- tests/e2e/specs/08-ui.e2e.ts | 9 +- tests/e2e/state.ts | 4 +- tests/routes/env.db-mode.spec.ts | 8 +- tests/routes/helpers/script-runner.ts | 19 +- 18 files changed, 279 insertions(+), 189 deletions(-) create mode 100644 tests/e2e/smoke-test-defaults.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df1c441..3865c2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,11 @@ jobs: - name: Run security audit run: | + audit_log="$(mktemp)" for attempt in 1 2 3; do - if bun audit; then + if bun audit >"$audit_log" 2>&1; then + cat "$audit_log" + rm -f "$audit_log" exit 0 fi if [ "$attempt" -lt 3 ]; then @@ -93,10 +96,18 @@ jobs: sleep 5 fi done - echo "bun audit failed after retries" + + audit_output="$(cat "$audit_log")" + if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' "$audit_log"; then + echo "bun audit failed after retries due to network/registry error: $audit_output" + else + echo "bun audit failed after retries - vulnerabilities detected: $audit_output" + fi + rm -f "$audit_log" exit 1 - name: Check for secrets + # Pinned to immutable commit (v3.93.4) for supply-chain safety. uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1627643..cf3579b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: echo "version_number=${NEW_VERSION#v}" >> $GITHUB_OUTPUT - name: Type check - run: bun run typecheck + run: bun run tsc --noEmit - name: Run backend tests run: bun run test:unit diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 4f65b5c..301fbe8 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -390,7 +390,7 @@ const PeerList: React.FC = ({ window.removeEventListener('peerStatusUpdate', handlePeerUpdate as EventListener); window.removeEventListener('peerPingUpdate', handlePeerUpdate as EventListener); }; - }, [isSignerRunning]); + }, [authHeaders, isSignerRunning]); // Ping individual peer const handlePingPeer = useCallback(async (peerPubkey: string) => { @@ -434,7 +434,7 @@ const PeerList: React.FC = ({ return newSet; }); } - }, [isSignerRunning]); + }, [authHeaders, isSignerRunning]); const updatePeerPolicy = useCallback(async (peer: PeerStatus, changes: { allowSend?: boolean; allowReceive?: boolean }) => { if (!isSignerRunning || disabled) { @@ -525,7 +525,7 @@ const PeerList: React.FC = ({ return; } try { - const response = await fetch('/api/peers/ping', { + await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -534,14 +534,12 @@ const PeerList: React.FC = ({ body: JSON.stringify({ target: 'all' }) }); - const result = await response.json(); - // Refresh peer list after pinging all await fetchPeers(); } catch (error) { console.warn('[PeerList] Ping all failed:', error); } - }, [isSignerRunning, peers.length, fetchPeers]); + }, [authHeaders, isSignerRunning, peers.length, fetchPeers]); // Enhanced refresh that includes pinging const handleRefresh = useCallback(async () => { @@ -656,7 +654,7 @@ const PeerList: React.FC = ({ )}
-
e.stopPropagation()} className="flex-shrink-0"> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()} className="flex-shrink-0"> {actions}
@@ -667,7 +665,9 @@ const PeerList: React.FC = ({ "transition-all duration-300 ease-in-out overflow-hidden", isExpanded ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0" )} + aria-hidden={!isExpanded} > + {isExpanded && (
{isLoading ? (
@@ -772,10 +772,7 @@ const PeerList: React.FC = ({
Policy: out {outboundPolicy.statusLabel}, in {inboundPolicy.statusLabel} - + {policyBadgeLabel}
@@ -898,6 +895,7 @@ const PeerList: React.FC = ({
)} + )} ); diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index ca6ff00..b4a4e8e 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -270,7 +270,7 @@ Unaffected: `GET /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the For this reason: - The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. -- The "new API key can authenticate" test uses `GET /api/status` (public) which trivially returns 200; this confirms the key is created but does not actually exercise DB-key auth enforcement. +- The "new API key can authenticate" test uses `GET /api/event-log` to exercise real API-key auth enforcement on a protected endpoint. ### Event log export is NDJSON, not JSON diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index d132d4b..661c492 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset. Setting either to `false` forces publish errors to surface. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 629ae27..2f6f87f 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -42,6 +42,17 @@ describe('getValidRelays', () => { else process.env.ALLOW_LOCALHOST_RELAY = previous; } }); + + it('filters localhost hostname relay when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); }); describe('normalizeRelayListForEcho', () => { @@ -55,4 +66,15 @@ describe('normalizeRelayListForEcho', () => { else process.env.ALLOW_LOCALHOST_RELAY = previous; } }); + + it('keeps localhost hostname in echo list when explicitly allowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'true'; + try { + expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index c9086fe..67d70e8 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -107,7 +107,9 @@ export function getValidRelays( } // Helper functions for .env file management -const ENV_FILE_PATH = process.env.ENV_FILE_PATH?.trim() || '.env'; +function getEnvFilePath(): string { + return process.env.ENV_FILE_PATH?.trim() || '.env'; +} // Security: Whitelist of allowed environment variable keys (for write/validation) // IMPORTANT: SESSION_SECRET must NEVER be included here - it's strictly server-only @@ -257,8 +259,9 @@ function stringifyEnvFile(env: Record): string { export async function readEnvFile(): Promise> { try { - await fs.access(ENV_FILE_PATH); - const content = await fs.readFile(ENV_FILE_PATH, 'utf-8'); + const envFilePath = getEnvFilePath(); + await fs.access(envFilePath); + const content = await fs.readFile(envFilePath, 'utf-8'); const fileEnv = parseEnvFile(content); // Merge with actual environment variables as fallback @@ -301,7 +304,7 @@ function getEnvVarsFromProcess(): Record { // Get the modification time of the environment file export async function getEnvFileModTime(): Promise { try { - const stats = await fs.stat(ENV_FILE_PATH); + const stats = await fs.stat(getEnvFilePath()); return stats.mtime.toISOString(); } catch (error) { // File doesn't exist or error accessing it @@ -365,7 +368,7 @@ export async function writeEnvFileWithTimestamp(env: Record): Pr } const content = stringifyEnvFile(env); - await fs.writeFile(ENV_FILE_PATH, content, 'utf-8'); + await fs.writeFile(getEnvFilePath(), content, 'utf-8'); return true; } catch (error) { console.error('Error writing .env file:', error); @@ -376,7 +379,7 @@ export async function writeEnvFileWithTimestamp(env: Record): Pr export async function writeEnvFile(env: Record): Promise { try { const content = stringifyEnvFile(env); - await fs.writeFile(ENV_FILE_PATH, content, 'utf-8'); + await fs.writeFile(getEnvFilePath(), content, 'utf-8'); return true; } catch (error) { console.error('Error writing .env file:', error); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index a598fbb..60eb439 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -52,16 +52,16 @@ try { }); node.on('closed', () => console.log('[cosigner] Node closed')); node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); - node.on('bounced', (...args) => console.log('[cosigner] Bounced:', JSON.stringify(args).slice(0, 200))); + node.on('bounced', (...args) => console.log('[cosigner] Bounced:', safeStringify(args).slice(0, 200))); node.on('message', (msg) => { console.log('[cosigner] Message tag:', msg?.tag, '| from:', msg?.env?.pubkey?.slice(0,16)); }); node.on('/sign/handler/req', (msg) => console.log('[cosigner] SIGN REQ received, id:', msg?.id)); node.on('/sign/handler/res', () => console.log('[cosigner] SIGN RES sent')); - node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', JSON.stringify(a).slice(0, 200))); + node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', safeStringify(a).slice(0, 200))); // Also spy on the raw WebSocket to confirm relay subscription - node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', JSON.stringify(a).slice(0, 100))); + node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', safeStringify(a).slice(0, 100))); console.log('[cosigner] Connecting to relay:', relayUrl); await connectNode(node); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index ade0314..2c8d311 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; +import type { SmokeTestState } from './state.js'; const REQUESTED_PORT_RAW = process.env.SMOKE_TEST_PORT ?? '18002'; const REQUESTED_PORT = Number.parseInt(REQUESTED_PORT_RAW, 10); @@ -31,34 +32,38 @@ const DB_PATH = path.join(TMP_DIR, 'db'); const SERVER_LOG = path.join(TMP_DIR, 'server.log'); const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); -// Defaults are safe for local CI, but callers should override via environment variables. -const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; -const ADMIN_SECRET = process.env.ADMIN_SECRET ?? 'SmokeTestAdmin1'; -const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? 'testadmin'; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'T3stPass@9'; - -type SetupState = { - port: number; - baseUrl: string; - tmpDir: string; - serverPid: number; - cosignerPid: number; - sessionId: string; - apiKey: string | null; - apiKeyId: string | null; - groupCredential: string; - shareCredentials: string[]; - groupPubkeyHex: string; +const smokeDefaultsPath = path.resolve('tests/e2e/smoke-test-defaults.json'); +const smokeDefaultsRaw = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); +if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { + throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); +} +const requiredKeys = ['testNsecHex', 'adminSecret', 'adminUsername', 'adminPassword'] as const; +const raw = smokeDefaultsRaw as Record; +const missing = requiredKeys.filter(k => raw[k] == null || typeof raw[k] !== 'string'); +if (missing.length > 0) { + throw new Error( + `smoke-test-defaults.json is missing required string properties: ${missing.join(', ')}. ` + + `Expected: ${requiredKeys.join(', ')}`, + ); +} +const smokeDefaults = raw as { + testNsecHex: string; + adminSecret: string; adminUsername: string; adminPassword: string; - adminSecret: string; }; +// Defaults come from fixture for local CI; callers can still override via environment. +const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex; +const ADMIN_SECRET = process.env.ADMIN_SECRET ?? smokeDefaults.adminSecret; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? smokeDefaults.adminUsername; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? smokeDefaults.adminPassword; + function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -function writeState(state: SetupState) { +function writeState(state: SmokeTestState) { fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); process.env.SMOKE_STATE_FILE = STATE_FILE; } @@ -176,7 +181,7 @@ export default async function globalSetup(_config: FullConfig): Promise { const port = await resolvePort(host, DEFAULT_PORT); const baseUrl = `http://${host}:${port}`; - const state: SetupState = { + const state: SmokeTestState = { port, baseUrl, tmpDir: TMP_DIR, diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 64e26f1..bb4a4ad 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -7,21 +7,29 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import type { FullConfig } from '@playwright/test'; +import type { SmokeTestState } from './state.js'; function findLatestStateFile(): string | null { const tmpRoot = os.tmpdir(); let latestFile: string | null = null; let latestMtime = 0; - - for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; - const candidate = path.join(tmpRoot, entry.name, 'state.json'); - if (!fs.existsSync(candidate)) continue; - const mtime = fs.statSync(candidate).mtimeMs; - if (mtime > latestMtime) { - latestMtime = mtime; - latestFile = candidate; + try { + for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; + const candidate = path.join(tmpRoot, entry.name, 'state.json'); + if (!fs.existsSync(candidate)) continue; + try { + const mtime = fs.statSync(candidate).mtimeMs; + if (mtime > latestMtime) { + latestMtime = mtime; + latestFile = candidate; + } + } catch { + // Ignore transient stat/read errors when scanning tmp entries. + } } + } catch { + return null; } return latestFile; @@ -40,9 +48,9 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } - let state: { serverPid?: number; cosignerPid?: number; tmpDir?: string }; + let state: SmokeTestState; try { - state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')); + state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as SmokeTestState; } catch { console.warn('[teardown] Could not parse state file.'); return; @@ -65,25 +73,23 @@ export default async function globalTeardown(_config: FullConfig): Promise await new Promise(r => setTimeout(r, 500)); const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); - if (tmpDir) { - try { - const resolvedTmp = path.resolve(tmpDir); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); + try { + const resolvedTmp = path.resolve(tmpDir); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); - if (!isInsideTemp) { - console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); - } else { - fs.rmSync(resolvedTmp, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', resolvedTmp); - } - } catch (err) { - console.warn('[teardown] Could not remove temp dir:', err); + if (!isInsideTemp) { + console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); + } else { + fs.rmSync(resolvedTmp, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', resolvedTmp); } + } catch (err) { + console.warn('[teardown] Could not remove temp dir:', err); } } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index a4cd656..375bb63 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; export async function loginAs(page: Page, username: string, password: string): Promise { @@ -13,4 +14,8 @@ export async function loginAs(page: Page, username: string, password: string): P await passwordField.fill(password); await submitBtn.click(); await page.waitForLoadState('networkidle'); + await expect( + page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first(), + 'login failed: expected dashboard tabs after submit' + ).toBeVisible({ timeout: 10_000 }); } diff --git a/tests/e2e/smoke-test-defaults.json b/tests/e2e/smoke-test-defaults.json new file mode 100644 index 0000000..298a223 --- /dev/null +++ b/tests/e2e/smoke-test-defaults.json @@ -0,0 +1,6 @@ +{ + "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "adminSecret": "SmokeTestAdmin1", + "adminUsername": "testadmin", + "adminPassword": "T3stPass@9" +} diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 65d3d72..60b2300 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -7,8 +7,17 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +type SignEventPayload = { + pubkey: string; + kind: number; + created_at: number; + content: string; + tags: string[][]; +}; + +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, groupPubkeyHex } = state; // Valid 32-byte hex event IDs for signing @@ -78,7 +87,7 @@ test.describe('Sign – /api/sign', () => { test('signs a full event object and returns signature', async () => { const api = await request.newContext({ baseURL: baseUrl }); // Use the group pubkey as the event author pubkey - const event = { + const event: SignEventPayload = { pubkey: groupPubkeyHex, kind: 1, created_at: Math.floor(Date.now() / 1000), @@ -113,16 +122,17 @@ test.describe('Sign – /api/sign', () => { test('event with invalid pubkey returns 400', async () => { const api = await request.newContext({ baseURL: baseUrl }); + const invalidEvent: SignEventPayload = { + pubkey: 'not-64-hex', + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'bad', + tags: [], + }; const res = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, data: { - event: { - pubkey: 'not-64-hex', - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'bad', - tags: [], - }, + event: invalidEvent, }, }); expect(res.status()).toBe(400); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index e3cff89..ba914bb 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -5,135 +5,141 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; const state = loadState(); const { baseUrl, sessionId, adminUsername } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Admin – API keys', () => { test('GET /api/admin/api-keys returns list', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('apiKeys'); + expect(Array.isArray(body.apiKeys)).toBe(true); + // At minimum the key created in global setup should be here + expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('apiKeys'); - expect(Array.isArray(body.apiKeys)).toBe(true); - // At minimum the key created in global setup should be here - expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); - await api.dispose(); }); test('POST /api/admin/api-keys creates a new key', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'temp-test-key' }, + await withApi(async (api) => { + const res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'temp-test-key' }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('apiKey'); + expect(body.apiKey).toHaveProperty('token'); + expect(typeof body.apiKey.token).toBe('string'); + expect(body.apiKey.token.length).toBeGreaterThan(20); + expect(body.apiKey).toHaveProperty('id'); }); - expect(res.status()).toBe(201); - const body = await res.json(); - expect(body).toHaveProperty('apiKey'); - expect(body.apiKey).toHaveProperty('token'); - expect(typeof body.apiKey.token).toBe('string'); - expect(body.apiKey.token.length).toBeGreaterThan(20); - expect(body.apiKey).toHaveProperty('id'); - await api.dispose(); }); test('new API key can authenticate', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - // Create key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'auth-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - - // Use key to hit an auth-protected route (status is intentionally public). - const authRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, + await withApi(async (api) => { + // Create key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'auth-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + + // Use key to hit an auth-protected route. + const authRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(authRes.status()).toBe(200); }); - expect(authRes.status()).toBe(200); - - await api.dispose(); }); test('revoked API key returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - // Create a fresh key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'revoke-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - - // Verify it works on an auth-protected endpoint - const beforeRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, + await withApi(async (api) => { + // Create a fresh key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'revoke-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + + // Verify it works on an auth-protected endpoint + const beforeRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(beforeRes.status()).toBe(200); + + // Revoke it + const revokeRes = await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, + }); + expect(revokeRes.status()).toBe(200); + + // Now the revoked key should be rejected + const afterRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(afterRes.status()).toBe(401); }); - expect(beforeRes.status()).toBe(200); - - // Revoke it - const revokeRes = await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, - }); - expect(revokeRes.status()).toBe(200); - - // Now the revoked key should be rejected - const afterRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(afterRes.status()).toBe(401); - - await api.dispose(); }); test('GET /api/admin/api-keys without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/api-keys'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/admin/api-keys'); + expect(res.status()).toBe(401); + }); }); }); test.describe('Admin – Users', () => { test('GET /api/admin/users returns user list', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/users', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/admin/users', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('users'); + expect(Array.isArray(body.users)).toBe(true); + expect(body.users.length).toBeGreaterThanOrEqual(1); + // Our admin user must be in the list + const found = body.users.some((u: { username: string }) => u.username === adminUsername); + expect(found).toBe(true); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('users'); - expect(Array.isArray(body.users)).toBe(true); - expect(body.users.length).toBeGreaterThanOrEqual(1); - // Our admin user must be in the list - const found = body.users.some((u: { username: string }) => u.username === adminUsername); - expect(found).toBe(true); - await api.dispose(); }); test('GET /api/admin/whoami returns admin identity', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/whoami', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/admin/whoami', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('userId'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('userId'); - await api.dispose(); }); test('GET /api/admin/users without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/users'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/admin/users'); + expect(res.status()).toBe(401); + }); }); }); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index d73ebfe..5e00398 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -8,8 +8,9 @@ import { test, expect } from '@playwright/test'; import { loginAs } from '../helpers.js'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, adminUsername, adminPassword } = state; test.describe('UI – Login page', () => { @@ -57,8 +58,10 @@ test.describe('UI – Authenticated app', () => { await configureTab.click(); // After clicking, the configure panel content should appear await page.waitForLoadState('networkidle'); - // Look for credential-related inputs or headings - const configContent = page.locator('input, textarea, [data-testid*="cred"]').first(); + // Scope to likely configure containers to avoid matching unrelated page inputs. + const configContent = page.locator( + '[role="tabpanel"] input, [role="tabpanel"] textarea, [role="tabpanel"] [data-testid*="cred"], [data-testid*="config"] input, [data-testid*="config"] textarea, [id*="config"] input, [id*="config"] textarea' + ).first(); await expect(configContent).toBeVisible({ timeout: 8_000 }); }); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index 033695a..ba8ecfc 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -22,7 +22,7 @@ export interface SmokeTestState { * Zod schema for SmokeTestState persisted by global-setup. * Validates shape and types before returning from loadState. */ -const smokeTestStateSchema = z.object({ +const SMOKE_TEST_STATE_SCHEMA = z.object({ port: z.number().int().positive(), baseUrl: z.string().min(1, 'baseUrl must be non-empty'), tmpDir: z.string(), @@ -67,7 +67,7 @@ export function loadState(): SmokeTestState { if (!stateFile) return STUB; try { const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - const result = smokeTestStateSchema.safeParse(parsed); + const result = SMOKE_TEST_STATE_SCHEMA.safeParse(parsed); if (!result.success) { const issues = result.error.issues .map(i => `${i.path.join('.')}: ${i.message}`) diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 67abb95..590083c 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -77,8 +77,12 @@ describe('DB-mode /api/env behavior', () => { updateNode: () => {} }; - // Generate real FROSTR credentials so validateGroup/validateShare pass - const { generateKeysetWithSecret } = await import(root + 'node_modules/@frostr/igloo-core/dist/index.js'); + // Generate real FROSTR credentials so validateGroup/validateShare pass. + // Resolve from project root because this script runs from a temp directory. + const { createRequire } = await import('module'); + const requireFromRoot = createRequire(root + 'package.json'); + const iglooCorePath = requireFromRoot.resolve('@frostr/igloo-core'); + const { generateKeysetWithSecret } = await import(iglooCorePath); const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); const headers = new Headers({ diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index e1f2aa7..36755b7 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -25,11 +25,11 @@ const ISOLATED_ENV_KEYS = [ 'AUTO_ADMIN_SECRET', 'SKIP_ADMIN_SECRET_VALIDATION', 'ENV_FILE_PATH', -]; +] as const; const ISOLATED_ENV_PREFIXES = [ 'RATE_LIMIT_', -]; +] as const; function buildScriptEnv(overrides: Record): Record { const nextEnv: Record = {}; @@ -56,7 +56,11 @@ function buildScriptEnv(overrides: Record): Record = {}) { +/** + * Runs route code in an isolated Bun subprocess and returns the parsed @@RESULT@@ JSON payload. + * Uses T=any by default so callers without an explicit type can access result properties (e.g. out.status). + */ +export function runRouteScript(code: string, env: Record = {}): T { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); @@ -89,7 +93,14 @@ export function runRouteScript(code: string, env: Record = {}) { if (!line) { throw new Error(`route script missing result marker: ${stdout}`); } - return JSON.parse(line.slice(line.indexOf(marker) + marker.length)); + const rawJson = line.slice(line.indexOf(marker) + marker.length); + try { + const parsed = JSON.parse(rawJson) as unknown; + return parsed as T; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`route script returned invalid JSON marker payload: ${detail}; raw="${rawJson}"; stdout="${stdout}"`); + } } finally { rmSync(tmpDir, { recursive: true, force: true }); } From 813aed6e17a8c1c1e322cd0b3b94aa1d6c9ba118 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 15:24:47 -0600 Subject: [PATCH 08/32] chore: harden e2e smoke flows and CI/env safeguards --- .github/workflows/ci.yml | 16 +- frontend/components/ui/peer-list.tsx | 10 +- llm/implementation/e2e-smoke-tests.md | 2 +- llm/implementation/umbrel-implementation.md | 2 +- src/routes/env.ts | 24 ++- src/routes/utils.test.ts | 17 ++ src/routes/utils.ts | 1 - tests/e2e/global-setup.ts | 14 +- tests/e2e/helpers.ts | 5 +- tests/e2e/specs/01-auth.e2e.ts | 3 +- tests/e2e/specs/02-status-peers.e2e.ts | 3 +- tests/e2e/specs/03-nip44-nip04.e2e.ts | 182 ++++++++++---------- tests/e2e/specs/04-sign.e2e.ts | 2 +- tests/e2e/specs/05-admin.e2e.ts | 68 +++++--- tests/e2e/specs/06-event-log.e2e.ts | 3 +- tests/e2e/specs/07-env.e2e.ts | 3 +- tests/routes/helpers/script-runner.ts | 2 +- 17 files changed, 211 insertions(+), 146 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3865c2d..2e863b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Type check run: bun run tsc --noEmit - - name: Run route tests + - name: Run unit tests run: bun run test:unit - name: Build frontend @@ -85,19 +85,25 @@ jobs: - name: Run security audit run: | audit_log="$(mktemp)" + last_exit=0 for attempt in 1 2 3; do if bun audit >"$audit_log" 2>&1; then cat "$audit_log" rm -f "$audit_log" exit 0 - fi - if [ "$attempt" -lt 3 ]; then - echo "bun audit failed (attempt $attempt), retrying..." - sleep 5 + else + audit_exit=$? + last_exit=$audit_exit + last_attempt=$attempt + if [ "$attempt" -lt 3 ]; then + echo "bun audit failed (attempt $attempt), retrying..." + sleep 5 + fi fi done audit_output="$(cat "$audit_log")" + echo "bun audit failed after ${last_attempt:-3} attempts with exit code ${last_exit}" if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' "$audit_log"; then echo "bun audit failed after retries due to network/registry error: $audit_output" else diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 301fbe8..d233246 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -615,7 +615,15 @@ const PeerList: React.FC = ({ position="right" width="w-64" focusable - trigger={} + trigger={( + + )} content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

} diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index b4a4e8e..e2ab29b 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -80,7 +80,7 @@ The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, l | `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | | `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | | `ALLOW_LOCALHOST_RELAY` | `true` | Allow `ws://127.0.0.1:18002` as a relay URL | -| `FROSTR_SIGN_TIMEOUT` | `15000` | Allow 15 s for threshold signing | +| `FROSTR_SIGN_TIMEOUT` | `5000` | Cap setup/sign probe latency to 5 s per request | | `GROUP_CRED` | `''` | Clear any `.env` credential interference | | `SHARE_CRED` | `''` | Clear any `.env` credential interference | | `RELAYS` | `''` | Clear any `.env` relay interference | diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index a3e7955..7562f55 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -66,7 +66,7 @@ These values are set in the store compose and expected by the UI flow: ## Operational Notes - Healthcheck uses `curl http://localhost:8002/api/status` with retries and start period. -- The Umbrel store `docker-compose.yml` intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. +- `igloo-server/docker-compose.yml` (Umbrel store artifact) intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. - `packages/umbrel/igloo/docker-compose.yml` is a sideload/dev bundle and also points at `:umbrel-dev` but without a pinned digest. ## Update Checklist for Future Releases diff --git a/src/routes/env.ts b/src/routes/env.ts index d204929..b1d2572 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -148,6 +148,18 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged hasValidHeadlessApiKey(r) || hasValidHeadlessBasic(r) ); + const extractNonEmptyAdminSecret = (r: Request): string | undefined => { + const headerSecret = r.headers.get('X-Admin-Secret')?.trim(); + if (headerSecret && headerSecret.length > 0) return headerSecret; + + const authHeader = r.headers.get('Authorization'); + if (!authHeader) return undefined; + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + if (!bearerMatch) return undefined; + const bearerToken = bearerMatch[1]?.trim(); + return bearerToken && bearerToken.length > 0 ? bearerToken : undefined; + }; + const isHeadlessReadAuthorized = (r: Request, a?: RequestAuth | null): boolean => { // If global auth is enabled and a session is present, allow; otherwise require API key or Basic. if (AUTH_CONFIG.ENABLED && a?.authenticated) return true; @@ -280,10 +292,8 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or // - allow when the authenticated DB user has role=admin. // validateAdminSecret() returns false when the header is missing; there is no bypass. - const authHeader = req.headers.get('Authorization'); - const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; - const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; - const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); + const adminSecret = extractNonEmptyAdminSecret(req); + const isAdminSecret = await validateAdminSecret(adminSecret); if (!isAdminSecret && !isRoleAdmin) { return Response.json( { error: 'Admin privileges required for environment modifications' }, @@ -665,10 +675,8 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged ); } if (!HEADLESS) { - const authHeader = req.headers.get('Authorization'); - const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; - const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; - const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); + const adminSecret = extractNonEmptyAdminSecret(req); + const isAdminSecret = await validateAdminSecret(adminSecret); if (!isAdminSecret && !isRoleAdmin) { return Response.json( { error: 'Admin privileges required for deleting environment variables' }, diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 2f6f87f..5d51a93 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -56,6 +56,23 @@ describe('getValidRelays', () => { }); describe('normalizeRelayListForEcho', () => { + it('filters localhost relays when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect( + normalizeRelayListForEcho([ + 'ws://127.0.0.1:18002', + 'ws://localhost:18002', + 'wss://relay.example.com' + ]) + ).toEqual(['wss://relay.example.com']); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); + it('keeps localhost relay in echo list when explicitly allowed', () => { const previous = process.env.ALLOW_LOCALHOST_RELAY; process.env.ALLOW_LOCALHOST_RELAY = 'true'; diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 67d70e8..0338bc6 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -816,7 +816,6 @@ export function normalizeRelayListForEcho(relays: any): string[] | undefined { .filter((r) => { try { const u = new URL(r); - if (u.protocol !== 'ws:' && u.protocol !== 'wss:') return false; if (!allowLocalhost && isLoopbackRelayHost(u.hostname)) return false; return true; } catch { diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 2c8d311..f3101bf 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -211,10 +211,8 @@ export default async function globalSetup(_config: FullConfig): Promise { writeState(state); console.log('[setup] Generating FROSTR credentials...'); - const { generateKeysetWithSecret, decodeGroup } = await import('@frostr/igloo-core') as { - generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; - decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; - }; + const iglooCore = await import('@frostr/igloo-core') as typeof import('@frostr/igloo-core'); + const { generateKeysetWithSecret, decodeGroup } = iglooCore; const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); const group = decodeGroup(groupCredential); @@ -238,7 +236,7 @@ export default async function globalSetup(_config: FullConfig): Promise { SKIP_STARTUP_ECHO: 'true', NODE_ENV: 'test', AUTH_ENABLED: 'true', - FROSTR_SIGN_TIMEOUT: '15000', + FROSTR_SIGN_TIMEOUT: '5000', UI_EVENT_LOG_INCLUDE_PINGS: 'false', UPDATE_CHECK_DISABLED: 'true', ALLOW_LOCALHOST_RELAY: 'true', @@ -321,14 +319,14 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); const TEST_MSG = 'a'.repeat(64); let signOk = false; - for (let attempt = 1; attempt <= 5; attempt++) { + for (let attempt = 1; attempt <= 4; attempt++) { if (cosignerProcess && cosignerProcess.exitCode !== null) { const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; throw new Error( `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` ); } - await sleep(3000); + await sleep(2000); const sr = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, data: { message: TEST_MSG }, @@ -343,7 +341,7 @@ export default async function globalSetup(_config: FullConfig): Promise { } if (!signOk) { const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error(`Co-signer did not become ready within 15 s.\nCo-signer log:\n${cosLog}`); + throw new Error(`Co-signer did not become ready within probe window.\nCo-signer log:\n${cosLog}`); } console.log('[setup] Creating test API key...'); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 375bb63..ec80733 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -6,14 +6,11 @@ export async function loginAs(page: Page, username: string, password: string): P .locator('input[type="text"], input[id*="user"], input[name*="user"]') .first(); const passwordField = page.locator('input[type="password"]').first(); - const submitBtn = page - .locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")') - .first(); + const submitBtn = page.getByRole('button', { name: /login|sign in/i }).first(); await usernameField.fill(username); await passwordField.fill(password); await submitBtn.click(); - await page.waitForLoadState('networkidle'); await expect( page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first(), 'login failed: expected dashboard tabs after submit' diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index e37a56b..176da8f 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -4,8 +4,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; test.describe('Auth – /api/auth', () => { diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index d5b4e2b..e864f47 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -4,8 +4,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; test.describe('Status – /api/status', () => { diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts index 60b3353..10b7e39 100644 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -6,6 +6,7 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; const state = loadState(); @@ -13,71 +14,78 @@ const { baseUrl, sessionId, groupPubkeyHex } = state; const PLAINTEXT = 'Hello from igloo smoke test!'; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + // ─── NIP-44 ────────────────────────────────────────────────────────────────── test.describe('NIP-44 – /api/nip44', () => { test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('encrypt returns ciphertext', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + expect(typeof body.result).toBe('string'); + expect(body.result.length).toBeGreaterThan(0); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - expect(typeof body.result).toBe('string'); - expect(body.result.length).toBeGreaterThan(0); - await api.dispose(); }); test('encrypt then decrypt round-trips plaintext', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - const encRes = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const encRes = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip44/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip44/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, - }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - - await api.dispose(); }); test('invalid peer_pubkey returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('missing content returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); }); @@ -85,66 +93,64 @@ test.describe('NIP-44 – /api/nip44', () => { test.describe('NIP-04 – /api/nip04', () => { test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('encrypt returns ciphertext with IV suffix', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + // NIP-04 ciphertext has the form ?iv= + expect(body.result).toMatch(/\?iv=/); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - // NIP-04 ciphertext has the form ?iv= - expect(body.result).toMatch(/\?iv=/); - await api.dispose(); }); test('encrypt then decrypt round-trips plaintext', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - const encRes = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip04/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + await withApi(async (api) => { + const encRes = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip04/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - - await api.dispose(); }); test('invalid peer_pubkey returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('missing content returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 60b2300..76e28d1 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -1,7 +1,7 @@ /** * Signing smoke tests – requires the igloo-cli co-signer launched in global setup. * - * sign timeout: 15 s (FROSTR_SIGN_TIMEOUT env set in global-setup). + * sign timeout: 5 s (FROSTR_SIGN_TIMEOUT env set in global-setup for smoke runs). * Test timeout overridden to 30 s to accommodate the signing round-trip. */ diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index ba914bb..68ed449 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -37,35 +37,57 @@ test.describe('Admin – API keys', () => { test('POST /api/admin/api-keys creates a new key', async () => { await withApi(async (api) => { - const res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'temp-test-key' }, - }); - expect(res.status()).toBe(201); - const body = await res.json(); - expect(body).toHaveProperty('apiKey'); - expect(body.apiKey).toHaveProperty('token'); - expect(typeof body.apiKey.token).toBe('string'); - expect(body.apiKey.token.length).toBeGreaterThan(20); - expect(body.apiKey).toHaveProperty('id'); + let createdKeyId: string | number | null = null; + try { + const res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: `temp-test-key-${Date.now()}` }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('apiKey'); + expect(body.apiKey).toHaveProperty('token'); + expect(typeof body.apiKey.token).toBe('string'); + expect(body.apiKey.token.length).toBeGreaterThan(20); + expect(body.apiKey).toHaveProperty('id'); + createdKeyId = body.apiKey.id; + } finally { + if (createdKeyId !== null) { + await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, + }).catch(() => null); + } + } }); }); test('new API key can authenticate', async () => { await withApi(async (api) => { - // Create key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'auth-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); + let createdKeyId: string | number | null = null; + try { + // Create key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: `auth-test-key-${Date.now()}` }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + createdKeyId = apiKey.id; - // Use key to hit an auth-protected route. - const authRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(authRes.status()).toBe(200); + // Use key to hit an auth-protected route. + const authRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(authRes.status()).toBe(200); + } finally { + if (createdKeyId !== null) { + await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, + }).catch(() => null); + } + } }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 3bcda19..4e2efd1 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -5,8 +5,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; test.describe('Event log – /api/event-log', () => { diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts index 61017e3..8879bfe 100644 --- a/tests/e2e/specs/07-env.e2e.ts +++ b/tests/e2e/specs/07-env.e2e.ts @@ -8,8 +8,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; test.describe('Env / credentials – /api/env', () => { diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 36755b7..32288eb 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -89,7 +89,7 @@ export function runRouteScript(code: string, env: Record l.includes(marker)); + const line = [...stdout.split('\n')].reverse().find(l => l.includes(marker)); if (!line) { throw new Error(`route script missing result marker: ${stdout}`); } From 609544b8b3522ee64a7fde48754feec2596794f4 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 16:03:15 -0600 Subject: [PATCH 09/32] fix: tighten smoke test docs and cleanup guards --- llm/implementation/e2e-smoke-tests.md | 2 +- llm/implementation/umbrel-implementation.md | 2 +- src/routes/utils.test.ts | 65 ++++++++------------- tests/e2e/global-setup.ts | 23 ++++++-- tests/e2e/global-teardown.ts | 20 ++++++- tests/e2e/state.ts | 8 +-- 6 files changed, 65 insertions(+), 55 deletions(-) diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index e2ab29b..e75507f 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -266,7 +266,7 @@ This is by design — unauthenticated health checks and monitoring probes must b Database-backed API keys authenticate via `authenticateDatabaseApiKey()` and return `userId: 'api-key:'` — a string, not a numeric DB row ID. Several routes in DB mode call `getCredentials(auth)` which requires a numeric `userId` to decrypt per-user credentials from SQLite. If `userId` is not numeric, `getCredentials` returns `null` and the route responds 401. Affected routes: `GET /api/peers`, `GET /api/peers/group`, `GET /api/peers/self`. -Unaffected: `GET /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). +Unaffected: `POST /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). For this reason: - The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 7562f55..d2eb91d 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -72,5 +72,5 @@ These values are set in the store compose and expected by the UI flow: ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). 2. Update the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). -3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. +3. Update `igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 5d51a93..80c9d6c 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'bun:test'; import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; +function withEnv(key: string, value: string, fn: () => void): void { + const previous = process.env[key]; + process.env[key] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +} + describe('getValidRelays', () => { it('returns default relay when fallback is enabled and input is empty', () => { expect(getValidRelays()).toEqual(['wss://relay.primal.net']); @@ -22,44 +33,27 @@ describe('getValidRelays', () => { }); it('filters IPv6 localhost relay when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('filters localhost hostname relay when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); }); describe('normalizeRelayListForEcho', () => { it('filters localhost relays when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect( normalizeRelayListForEcho([ 'ws://127.0.0.1:18002', @@ -67,31 +61,18 @@ describe('normalizeRelayListForEcho', () => { 'wss://relay.example.com' ]) ).toEqual(['wss://relay.example.com']); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('keeps localhost relay in echo list when explicitly allowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'true'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('keeps localhost hostname in echo list when explicitly allowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'true'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index f3101bf..b8e0d81 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -59,16 +59,16 @@ const ADMIN_SECRET = process.env.ADMIN_SECRET ?? smokeDefaults.adminSecret; const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? smokeDefaults.adminUsername; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? smokeDefaults.adminPassword; -function sleep(ms: number) { +function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -function writeState(state: SmokeTestState) { +function writeState(state: SmokeTestState): void { fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); process.env.SMOKE_STATE_FILE = STATE_FILE; } -function terminateProcess(proc: ChildProcess | null, label: string) { +function terminateProcess(proc: ChildProcess | null, label: string): void { if (!proc?.pid) return; try { process.kill(proc.pid, 'SIGTERM'); @@ -205,7 +205,22 @@ export default async function globalSetup(_config: FullConfig): Promise { process.env.SMOKE_STATE_FILE = STATE_FILE; try { - if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + const resolvedTmp = path.resolve(TMP_DIR); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); + + if (fs.existsSync(TMP_DIR)) { + if (isInsideTemp) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } else { + console.warn('[setup] Skipping TMP_DIR cleanup outside os.tmpdir():', resolvedTmp); + } + } fs.mkdirSync(TMP_DIR, { recursive: true }); fs.mkdirSync(DB_PATH, { recursive: true }); writeState(state); diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index bb4a4ad..21beb91 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -48,12 +48,26 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } - let state: SmokeTestState; + let state: SmokeTestState = { + port: 0, + baseUrl: '', + tmpDir: path.dirname(resolvedStateFile), + serverPid: 0, + cosignerPid: 0, + sessionId: '', + apiKey: null, + apiKeyId: null, + groupCredential: '', + shareCredentials: [], + groupPubkeyHex: '', + adminUsername: '', + adminPassword: '', + adminSecret: '', + }; try { state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as SmokeTestState; } catch { - console.warn('[teardown] Could not parse state file.'); - return; + console.warn('[teardown] Could not parse state file; using fallback cleanup state.'); } for (const [label, pid] of [['co-signer', state.cosignerPid], ['server', state.serverPid]] as const) { diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index ba8ecfc..a05698d 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -31,12 +31,12 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ sessionId: z.string().min(1, 'sessionId must be non-empty'), apiKey: z.string().nullable(), apiKeyId: z.string().nullable(), - groupCredential: z.string(), - shareCredentials: z.array(z.string()), + groupCredential: z.string().min(1, 'groupCredential must be non-empty'), + shareCredentials: z.array(z.string().min(1, 'share credential must be non-empty')), groupPubkeyHex: z.string(), adminUsername: z.string(), - adminPassword: z.string(), - adminSecret: z.string(), + adminPassword: z.string().min(1, 'adminPassword must be non-empty'), + adminSecret: z.string().min(1, 'adminSecret must be non-empty'), }); const STUB: SmokeTestState = { From 657c79c1590e4ec0c4abbeee8e57d33ddbeb9672 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 16:43:31 -0600 Subject: [PATCH 10/32] fix: harden relay req validation and test safety --- src/class/relay.ts | 13 +++++++++++-- src/routes/utils.test.ts | 7 +++++++ tests/e2e/global-setup.ts | 2 +- tests/routes/env.db-mode.spec.ts | 6 +++++- tests/routes/helpers/script-runner.ts | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/class/relay.ts b/src/class/relay.ts index f4dab29..2cf6914 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -155,10 +155,20 @@ class RelaySession { // Normalize nostr-tools 2.x format where filters are wrapped in an extra array: // New format: ["REQ", "sub_id", [{filter1}, {filter2}]] // NIP-01 format: ["REQ", "sub_id", {filter1}, {filter2}] + if (payload.length === 2 && Array.isArray(payload[1]) && payload[1].length === 0) { + this.log.info('ignoring REQ with empty filter array') + this.send(['NOTICE', '', 'REQ requires at least one filter']) + return + } if (payload.length === 2 && Array.isArray(payload[1])) { payload = [payload[0], ...payload[1]] } const [ id, ...filters ] = sub_schema.parse(payload) + if (filters.length === 0) { + this.log.info('ignoring REQ with no filters') + this.send(['NOTICE', '', 'REQ requires at least one filter']) + return + } return this._onreq(id, filters) case 'EVENT': const event = Nostr.parse_event(payload.at(0), this.relay.config.debug) @@ -191,8 +201,7 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.info('event failed validation (id=' + event.id.slice(0, 8) + ' kind=' + event.kind + ')') - this.log.debug('event details:', event) + this.log.info(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 80c9d6c..ffed2bd 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -49,6 +49,13 @@ describe('getValidRelays', () => { expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); + + it('keeps localhost relay when localhost relays are explicitly allowed', () => { + withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) + .toEqual(['ws://127.0.0.1:18002']); + }); + }); }); describe('normalizeRelayListForEcho', () => { diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index b8e0d81..2e6e042 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -33,7 +33,7 @@ const SERVER_LOG = path.join(TMP_DIR, 'server.log'); const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); const smokeDefaultsPath = path.resolve('tests/e2e/smoke-test-defaults.json'); -const smokeDefaultsRaw = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); +const smokeDefaultsRaw: unknown = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); } diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 590083c..654f20d 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +const TEST_KEYSET_SECRET = + process.env.TEST_KEYSET_SECRET ?? + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { const script = ` @@ -83,7 +87,7 @@ describe('DB-mode /api/env behavior', () => { const requireFromRoot = createRequire(root + 'package.json'); const iglooCorePath = requireFromRoot.resolve('@frostr/igloo-core'); const { generateKeysetWithSecret } = await import(iglooCorePath); - const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, ${JSON.stringify(TEST_KEYSET_SECRET)}); const headers = new Headers({ 'Content-Type': 'application/json', diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 32288eb..1d23378 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -89,7 +89,7 @@ export function runRouteScript(code: string, env: Record l.includes(marker)); + const line = stdout.split('\n').reverse().find(l => l.includes(marker)); if (!line) { throw new Error(`route script missing result marker: ${stdout}`); } From 959edf4a101038f3e39767aa11bff514190b1ed7 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 17:04:38 -0600 Subject: [PATCH 11/32] fix: harden e2e cleanup, docs, and context handling --- .gitignore | 1 + .../node-lifecycle-implementation.md | 2 +- llm/implementation/umbrel-implementation.md | 4 +- tests/e2e/cosigner.mjs | 6 +- tests/e2e/global-teardown.ts | 16 ++ tests/e2e/specs/03-nip44-nip04.e2e.ts | 3 +- tests/e2e/specs/04-sign.e2e.ts | 166 ++++++++++-------- tests/e2e/specs/06-event-log.e2e.ts | 1 + 8 files changed, 115 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index eda8834..9945c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ test-*.sh debug-*.js verify-*.md test-results/ +playwright-report/ # LLM files .claude diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index 661c492..b605e7a 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. To force publish errors to surface, set `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false`; if `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset, set `RELAY_ALLOW_BENIGN_SWALLOW=false`. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index d2eb91d..36d643b 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -71,6 +71,6 @@ These values are set in the store compose and expected by the UI flow: ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). -3. Update `igloo-server/umbrel-app.yml` version and release notes. +2. Bump the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). +3. Revise `igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 60eb439..6b11c32 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -79,12 +79,14 @@ try { } } catch (err) { - console.error('[cosigner] Failed to start:', err.message ?? err); + console.error('[cosigner] Failed to start:', err instanceof Error ? err.message : String(err)); process.exit(2); } const shutdown = () => { - try { node?.close?.(); } catch {} + try { node?.close?.(); } catch (e) { + console.error('[cosigner] Error closing node:', e instanceof Error ? e.message : String(e)); + } process.exit(0); }; process.on('SIGTERM', shutdown); diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 21beb91..ff53eb1 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -9,6 +9,8 @@ import path from 'path'; import type { FullConfig } from '@playwright/test'; import type { SmokeTestState } from './state.js'; +const MAX_STATE_AGE_MS = 10 * 60 * 1000; + function findLatestStateFile(): string | null { const tmpRoot = os.tmpdir(); let latestFile: string | null = null; @@ -48,6 +50,20 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } + try { + const ageMs = Date.now() - fs.statSync(resolvedStateFile).mtimeMs; + if (ageMs > MAX_STATE_AGE_MS) { + console.warn( + `[teardown] State file is stale (${Math.round(ageMs / 1000)}s old); ` + + 'skipping process kill and temp cleanup to avoid affecting unrelated runs.' + ); + return; + } + } catch (error) { + console.warn('[teardown] Could not stat state file; skipping cleanup for safety:', error); + return; + } + let state: SmokeTestState = { port: 0, baseUrl: '', diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts index 10b7e39..4657b49 100644 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -8,8 +8,9 @@ import { test, expect, request } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, groupPubkeyHex } = state; const PLAINTEXT = 'Hello from igloo smoke test!'; diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 76e28d1..5cfc047 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -6,6 +6,7 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; @@ -24,118 +25,127 @@ const { baseUrl, sessionId, groupPubkeyHex } = state; const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Sign – /api/sign', () => { // Explicit per-suite timeout for signing flows; global timeout is also 30_000. test.setTimeout(30_000); test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - data: { message: EVENT_ID_A }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('returns 400 for invalid (non-hex) message', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'not-hex' }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'not-hex' }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('returns 400 for message shorter than 32 bytes', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'deadbeef' }, // only 4 bytes + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'deadbeef' }, // only 4 bytes + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('returns 400 for missing body', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: {}, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: {}, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('signs a 32-byte hex message and returns signature', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: EVENT_ID_A }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('id', EVENT_ID_A); + expect(body).toHaveProperty('signature'); + expect(typeof body.signature).toBe('string'); + // Schnorr signature = 64 bytes = 128 hex chars + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('id', EVENT_ID_A); - expect(body).toHaveProperty('signature'); - expect(typeof body.signature).toBe('string'); - // Schnorr signature = 64 bytes = 128 hex chars - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); - await api.dispose(); }); test('signs a full event object and returns signature', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - // Use the group pubkey as the event author pubkey - const event: SignEventPayload = { - pubkey: groupPubkeyHex, - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'igloo smoke test', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { event }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + // Use the group pubkey as the event author pubkey + const event: SignEventPayload = { + pubkey: groupPubkeyHex, + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'igloo smoke test', + tags: [], + }; + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { event }, + }); + expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('id'); - expect(body).toHaveProperty('signature'); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); - await api.dispose(); + const body = await res.json(); + expect(body).toHaveProperty('id'); + expect(body).toHaveProperty('signature'); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + }); }); test('signing works with API key auth', async () => { test.skip(!state.apiKey, 'No API key available'); - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-API-Key': state.apiKey! }, - data: { message: EVENT_ID_B }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-API-Key': state.apiKey! }, + data: { message: EVENT_ID_B }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); - await api.dispose(); }); test('event with invalid pubkey returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const invalidEvent: SignEventPayload = { - pubkey: 'not-64-hex', - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'bad', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { - event: invalidEvent, - }, + await withApi(async (api) => { + const invalidEvent: SignEventPayload = { + pubkey: 'not-64-hex', + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'bad', + tags: [], + }; + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { + event: invalidEvent, + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 4e2efd1..0585383 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -71,6 +71,7 @@ test.describe('Event log – /api/event-log', () => { const text = await res.text(); // Each non-empty line must be valid JSON const lines = text.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(() => JSON.parse(line)).not.toThrow(); } From 6d73a9894c63253728eff3f3c9ba6dab7892d3d7 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 19:51:32 -0600 Subject: [PATCH 12/32] fix: harden e2e smoke setup and route test safety --- bun.lock | 4 +- frontend/components/ui/peer-list.tsx | 21 ++- package.json | 1 - src/class/relay.test.ts | 81 ++++++++++++ src/routes/env.ts | 17 +-- src/routes/utils.test.ts | 44 ++++--- src/routes/utils.ts | 42 +++++- tests/e2e/global-setup.ts | 71 ++++++++-- tests/e2e/global-teardown.ts | 146 +++++++++++---------- tests/e2e/helpers.ts | 2 +- tests/e2e/smoke-test-defaults.json | 5 +- tests/e2e/specs/01-auth.e2e.ts | 138 ++++++++++--------- tests/e2e/specs/02-status-peers.e2e.ts | 128 +++++++++--------- tests/e2e/specs/05-admin.e2e.ts | 2 + tests/e2e/specs/06-event-log.e2e.ts | 112 +++++++++------- tests/e2e/specs/07-env.e2e.ts | 124 +++++++++-------- tests/e2e/specs/08-ui.e2e.ts | 6 +- tests/e2e/state.ts | 2 +- tests/routes/env.db-mode.spec.ts | 23 +++- tests/routes/helpers/script-runner.spec.ts | 67 ++++++++++ tests/routes/helpers/script-runner.ts | 45 ++++--- 21 files changed, 711 insertions(+), 370 deletions(-) create mode 100644 src/class/relay.test.ts create mode 100644 tests/routes/helpers/script-runner.spec.ts diff --git a/bun.lock b/bun.lock index c9f0e76..ce4e1de 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "react-dom": "^18.3.1", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", + "zod": "^3.25.76", }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -31,7 +32,6 @@ "@types/node": "^22.18.12", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", - "@types/yaml": "^1.9.7", "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", @@ -284,8 +284,6 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/yaml": ["@types/yaml@1.9.7", "", { "dependencies": { "yaml": "*" } }, "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA=="], - "@vbyte/buff": ["@vbyte/buff@1.0.2", "", {}, "sha512-h/3CU+9H6fWZzAfM9/ar9FpQdRfupYyL5ug3fJ9hofzSuWytojVUAT32pnBtTIatowGVburZ/qj0xSpTXWA8qA=="], "@vbyte/micro-lib": ["@vbyte/micro-lib@1.1.2", "", { "dependencies": { "@noble/curves": "^1.9.6", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", "@vbyte/buff": "^1.0.2", "zod": "4.0.14" } }, "sha512-SYOTaaY4zAxuIZPdzVQAyyxAi83ZGt+LtXsjyrUjYsQuWzipLsTIF0h5fyLccOn7Mi+4tY9fPUrvMZNQJDijQg=="], diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index d233246..192a01b 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -307,7 +307,7 @@ const PeerList: React.FC = ({ // Perform initial ping sweep setIsInitialPingSweep(true); try { - await fetch('/api/peers/ping', { + const pingResponse = await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -315,9 +315,13 @@ const PeerList: React.FC = ({ }, body: JSON.stringify({ target: 'all' }) }); - - // Refresh peer list after ping sweep - await fetchPeers(); + if (!pingResponse.ok) { + const detail = await pingResponse.text().catch(() => '(unreadable)'); + console.debug(`[PeerList] Initial ping sweep failed (${pingResponse.status}): ${detail}`); + } else { + // Refresh peer list after ping sweep + await fetchPeers(); + } } catch (pingError) { console.debug('Initial ping sweep failed:', pingError); // Don't set error state for ping failures @@ -525,7 +529,7 @@ const PeerList: React.FC = ({ return; } try { - await fetch('/api/peers/ping', { + const response = await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -533,7 +537,12 @@ const PeerList: React.FC = ({ }, body: JSON.stringify({ target: 'all' }) }); - + if (!response.ok) { + const detail = await response.text().catch(() => '(unreadable)'); + console.warn(`[PeerList] Ping all failed (${response.status}): ${detail}`); + return; + } + // Refresh peer list after pinging all await fetchPeers(); } catch (error) { diff --git a/package.json b/package.json index 4c98c50..51368fb 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "@types/node": "^22.18.12", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", - "@types/yaml": "^1.9.7", "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts new file mode 100644 index 0000000..6d716cb --- /dev/null +++ b/src/class/relay.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'bun:test'; +import { NostrRelay } from './relay.js'; + +type FakeSocket = { + data: any; + sent: string[]; + closed: boolean; + send: (message: string) => void; + close: () => void; +}; + +function createFakeSocket(): FakeSocket { + return { + data: null, + sent: [], + closed: false, + send(message: string) { + this.sent.push(message); + }, + close() { + this.closed = true; + }, + }; +} + +function decodeSent(socket: FakeSocket): unknown[][] { + return socket.sent.map((message) => JSON.parse(message) as unknown[]); +} + +describe('NostrRelay REQ handling', () => { + it('normalizes wrapped nostr-tools REQ filter arrays into subscriptions', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const handler = relay.handler(); + + handler.open?.(socket as any); + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); + + expect(relay.subs.size).toBe(1); + const [sub] = Array.from(relay.subs.values()); + expect(sub?.sub_id).toBe('sub-1'); + expect(sub?.filters).toHaveLength(1); + expect((sub?.filters[0] as { kinds?: number[] }).kinds).toEqual([1]); + + const messages = decodeSent(socket); + expect(messages).toContainEqual(['EOSE', 'sub-1']); + }); + + it('rejects REQ with an empty wrapped filter array', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const handler = relay.handler(); + + handler.open?.(socket as any); + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-empty', []])); + + expect(relay.subs.size).toBe(0); + const messages = decodeSent(socket); + expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); + }); + + it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const handler = relay.handler(); + + handler.open?.(socket as any); + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); + expect(relay.subs.size).toBe(1); + + handler.message?.(socket as any, JSON.stringify(['CLOSE', 'sub-close'])); + expect(relay.subs.size).toBe(0); + + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); + expect(relay.subs.size).toBe(1); + + handler.close?.(socket as any, 1000, 'test'); + expect(relay.subs.size).toBe(0); + expect(socket.closed).toBe(true); + }); +}); diff --git a/src/routes/env.ts b/src/routes/env.ts index b1d2572..a34899f 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -92,9 +92,12 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // but we keep the broader classification for clarity. const isWrite = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE'; // Resolve authenticated DB user id (database mode only) - const authenticatedNumericUserId = (!HEADLESS && auth?.authenticated && ( - typeof auth.userId === 'number' || (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) - )) ? BigInt(auth!.userId as any) : null; + const authenticatedNumericUserId = (() => { + if (HEADLESS || !auth?.authenticated) return null; + if (typeof auth.userId === 'number') return BigInt(auth.userId); + if (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) return BigInt(auth.userId); + return null; + })(); const isRoleAdmin = await (async () => { try { if (authenticatedNumericUserId === null) return false; @@ -351,14 +354,6 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged return Response.json({ success: false, message: 'Failed to update .env file' }, { status: 500, headers }); } - // Headless writes must be authorized by API key or Basic (sessions are not sufficient) - if (HEADLESS && !hasHeadlessWriteAuthorization(req)) { - return Response.json( - { error: 'Authentication required' }, - { status: 401, headers } - ); - } - let body; try { body = await parseJsonRequestBody(req); diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index ffed2bd..a7b4d6a 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'bun:test'; import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; -function withEnv(key: string, value: string, fn: () => void): void { +async function withEnv(key: string, value: string, fn: () => Promise | T): Promise { const previous = process.env[key]; process.env[key] = value; try { - fn(); + return await fn(); } finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; @@ -32,26 +32,38 @@ describe('getValidRelays', () => { expect(getValidRelays('["not-a-relay","ftp://example.com"]', { fallbackToDefault: false })).toEqual([]); }); - it('filters IPv6 localhost relay when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters IPv6 localhost relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); - it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); - it('filters localhost hostname relay when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters localhost hostname relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); - it('keeps localhost relay when localhost relays are explicitly allowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters IPv4-mapped IPv6 hex relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) .toEqual(['ws://127.0.0.1:18002']); }); @@ -59,8 +71,8 @@ describe('getValidRelays', () => { }); describe('normalizeRelayListForEcho', () => { - it('filters localhost relays when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters localhost relays when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect( normalizeRelayListForEcho([ 'ws://127.0.0.1:18002', @@ -71,14 +83,14 @@ describe('normalizeRelayListForEcho', () => { }); }); - it('keeps localhost relay in echo list when explicitly allowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + it('keeps localhost relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); }); }); - it('keeps localhost hostname in echo list when explicitly allowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + it('keeps localhost hostname in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 0338bc6..a54206b 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -32,13 +32,51 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { return hex.toLowerCase(); } +function isValidIpv4Address(hostname: string): boolean { + const octets = hostname.split('.'); + if (octets.length !== 4) return false; + return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); +} + +function decodeMappedIpv4(segment: string): string | null { + if (isValidIpv4Address(segment)) return segment; + const mappedHex = segment.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (!mappedHex) return null; + const hi = Number.parseInt(mappedHex[1], 16); + const lo = Number.parseInt(mappedHex[2], 16); + const decoded = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; + return isValidIpv4Address(decoded) ? decoded : null; +} + +function extractIpv4MappedIpv6(hostname: string): string | null { + const mappedPrefixes = [ + /^::ffff:(.+)$/i, + /^::ffff:0:(.+)$/i, + /^0:0:0:0:0:ffff:(.+)$/i, + /^0:0:0:0:ffff:0:(.+)$/i, + ]; + for (const pattern of mappedPrefixes) { + const match = hostname.match(pattern); + if (!match) continue; + const decoded = decodeMappedIpv4(match[1]); + if (decoded) return decoded; + } + return null; +} + function isLoopbackRelayHost(hostname: string): boolean { - const normalized = hostname.replace(/^\[(.*)\]$/, '$1'); + let normalized = hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); if (normalized === 'localhost' || normalized === '::1') return true; + + const mappedIpv4 = extractIpv4MappedIpv6(normalized); + if (mappedIpv4) { + normalized = mappedIpv4; + } + const octets = normalized.split('.'); if (octets.length !== 4) return false; if (octets[0] !== '127') return false; - return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); + return isValidIpv4Address(normalized); } // Helper function to get valid relay URLs diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 2e6e042..7d58bd6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -37,27 +37,76 @@ const smokeDefaultsRaw: unknown = JSON.parse(fs.readFileSync(smokeDefaultsPath, if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); } -const requiredKeys = ['testNsecHex', 'adminSecret', 'adminUsername', 'adminPassword'] as const; const raw = smokeDefaultsRaw as Record; -const missing = requiredKeys.filter(k => raw[k] == null || typeof raw[k] !== 'string'); -if (missing.length > 0) { +if (typeof raw.testNsecHex !== 'string' || raw.testNsecHex.trim().length === 0) { throw new Error( - `smoke-test-defaults.json is missing required string properties: ${missing.join(', ')}. ` + - `Expected: ${requiredKeys.join(', ')}`, + 'smoke-test-defaults.json is missing required non-empty string property: testNsecHex.', ); } const smokeDefaults = raw as { testNsecHex: string; +}; + +function loadOptionalLocalSmokeCredentials(): Partial<{ adminSecret: string; adminUsername: string; adminPassword: string; -}; +}> { + const localFixturePath = + process.env.SMOKE_LOCAL_FIXTURE_PATH?.trim() || + path.resolve('tests/e2e/smoke-test.local.json'); + if (!fs.existsSync(localFixturePath)) { + return {}; + } + try { + const localRaw: unknown = JSON.parse(fs.readFileSync(localFixturePath, 'utf8')); + if (typeof localRaw !== 'object' || localRaw === null) { + throw new Error('expected JSON object'); + } + const fixture = localRaw as Record; + return { + adminSecret: typeof fixture.adminSecret === 'string' && fixture.adminSecret.trim().length > 0 + ? fixture.adminSecret + : undefined, + adminUsername: typeof fixture.adminUsername === 'string' && fixture.adminUsername.trim().length > 0 + ? fixture.adminUsername + : undefined, + adminPassword: typeof fixture.adminPassword === 'string' && fixture.adminPassword.trim().length > 0 + ? fixture.adminPassword + : undefined, + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse local smoke fixture at ${localFixturePath}: ${detail}`); + } +} + +const localSmokeCredentials = loadOptionalLocalSmokeCredentials(); + +function requireNonEmptyString(value: string | undefined, errorMessage: string): string { + if (!value || value.trim().length === 0) { + throw new Error(errorMessage); + } + return value; +} // Defaults come from fixture for local CI; callers can still override via environment. const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex; -const ADMIN_SECRET = process.env.ADMIN_SECRET ?? smokeDefaults.adminSecret; -const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? smokeDefaults.adminUsername; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? smokeDefaults.adminPassword; +const MISSING_SMOKE_CREDS_MESSAGE = + 'Smoke admin credentials are required. Set SMOKE_ADMIN_SECRET, SMOKE_ADMIN_USERNAME, and ' + + 'SMOKE_ADMIN_PASSWORD (or provide tests/e2e/smoke-test.local.json).'; +const ADMIN_SECRET = requireNonEmptyString( + process.env.SMOKE_ADMIN_SECRET ?? process.env.ADMIN_SECRET ?? localSmokeCredentials.adminSecret, + MISSING_SMOKE_CREDS_MESSAGE +); +const ADMIN_USERNAME = requireNonEmptyString( + process.env.SMOKE_ADMIN_USERNAME ?? process.env.ADMIN_USERNAME ?? localSmokeCredentials.adminUsername, + MISSING_SMOKE_CREDS_MESSAGE +); +const ADMIN_PASSWORD = requireNonEmptyString( + process.env.SMOKE_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD ?? localSmokeCredentials.adminPassword, + MISSING_SMOKE_CREDS_MESSAGE +); function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -334,14 +383,14 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); const TEST_MSG = 'a'.repeat(64); let signOk = false; - for (let attempt = 1; attempt <= 4; attempt++) { + for (let attempt = 1; attempt <= 5; attempt++) { if (cosignerProcess && cosignerProcess.exitCode !== null) { const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; throw new Error( `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` ); } - await sleep(2000); + await sleep(3000); const sr = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, data: { message: TEST_MSG }, diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index ff53eb1..d5ea286 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -11,43 +11,74 @@ import type { SmokeTestState } from './state.js'; const MAX_STATE_AGE_MS = 10 * 60 * 1000; -function findLatestStateFile(): string | null { - const tmpRoot = os.tmpdir(); - let latestFile: string | null = null; - let latestMtime = 0; +function parsePositivePid(raw: unknown, fieldName: string): number | null { + if (raw == null || raw === 0) return null; + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) { + console.warn(`[teardown] Invalid ${fieldName}; expected positive integer PID, got:`, raw); + return null; + } + return raw; +} + +function isProcessRunning(pid: number): boolean { try { - for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; - const candidate = path.join(tmpRoot, entry.name, 'state.json'); - if (!fs.existsSync(candidate)) continue; - try { - const mtime = fs.statSync(candidate).mtimeMs; - if (mtime > latestMtime) { - latestMtime = mtime; - latestFile = candidate; - } - } catch { - // Ignore transient stat/read errors when scanning tmp entries. - } + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM') return true; + return false; + } +} + +function resolveSafeTmpDir(rawTmpDir: unknown): string | null { + if (typeof rawTmpDir !== 'string' || rawTmpDir.trim().length === 0) { + console.warn('[teardown] Invalid tmpDir in state; skipping temp cleanup.'); + return null; + } + const resolvedTmp = path.resolve(rawTmpDir); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); + if (!isInsideTemp) { + console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); + return null; + } + if (!path.basename(resolvedTmp).startsWith('igloo-smoke-test-')) { + console.warn('[teardown] Refusing to remove unexpected temp dir name:', resolvedTmp); + return null; + } + try { + if (!fs.existsSync(resolvedTmp)) { + console.warn('[teardown] tmpDir does not exist; skipping temp cleanup:', resolvedTmp); + return null; } - } catch { + if (!fs.statSync(resolvedTmp).isDirectory()) { + console.warn('[teardown] tmpDir is not a directory; skipping temp cleanup:', resolvedTmp); + return null; + } + } catch (error) { + console.warn('[teardown] Could not validate tmpDir; skipping temp cleanup:', error); return null; } - - return latestFile; + return resolvedTmp; } export default async function globalTeardown(_config: FullConfig): Promise { const stateFile = process.env.SMOKE_STATE_FILE; - const resolvedStateFile = - stateFile && fs.existsSync(stateFile) - ? stateFile - : findLatestStateFile(); - console.log('[teardown] Resolved state file:', resolvedStateFile ?? '(none)'); + if (!stateFile || stateFile.trim().length === 0) { + throw new Error('[teardown] SMOKE_STATE_FILE is required; refusing to guess a state file.'); + } - if (!resolvedStateFile || !fs.existsSync(resolvedStateFile)) { - console.warn('[teardown] No state file found – nothing to clean up.'); - return; + const resolvedStateFile = path.resolve(stateFile); + console.log('[teardown] Resolved state file:', resolvedStateFile); + + if (!fs.existsSync(resolvedStateFile)) { + throw new Error(`[teardown] State file does not exist: ${resolvedStateFile}`); } try { @@ -64,61 +95,44 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } - let state: SmokeTestState = { - port: 0, - baseUrl: '', - tmpDir: path.dirname(resolvedStateFile), - serverPid: 0, - cosignerPid: 0, - sessionId: '', - apiKey: null, - apiKeyId: null, - groupCredential: '', - shareCredentials: [], - groupPubkeyHex: '', - adminUsername: '', - adminPassword: '', - adminSecret: '', - }; + let parsedState: Partial; try { - state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as SmokeTestState; - } catch { - console.warn('[teardown] Could not parse state file; using fallback cleanup state.'); + parsedState = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as Partial; + } catch (error) { + console.warn('[teardown] Could not parse state file; skipping cleanup for safety:', error); + return; } - for (const [label, pid] of [['co-signer', state.cosignerPid], ['server', state.serverPid]] as const) { + const cosignerPid = parsePositivePid(parsedState.cosignerPid, 'cosignerPid'); + const serverPid = parsePositivePid(parsedState.serverPid, 'serverPid'); + const safeTmpDir = resolveSafeTmpDir(parsedState.tmpDir); + + for (const [label, pid] of [['co-signer', cosignerPid], ['server', serverPid]] as const) { if (!pid) continue; + if (!isProcessRunning(pid)) { + console.warn(`[teardown] ${label} pid ${pid} is not running; skipping SIGTERM.`); + continue; + } try { process.kill(pid, 'SIGTERM'); console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); } catch (err: unknown) { - // ESRCH = process already gone, which is fine if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { console.warn(`[teardown] Could not kill ${label} (pid ${pid}):`, err); } } } - // Brief pause to let processes flush logs await new Promise(r => setTimeout(r, 500)); - const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); - try { - const resolvedTmp = path.resolve(tmpDir); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); + if (!safeTmpDir) { + console.warn('[teardown] Skipping temp dir removal due to invalid tmpDir state.'); + return; + } - if (!isInsideTemp) { - console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); - } else { - fs.rmSync(resolvedTmp, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', resolvedTmp); - } + try { + fs.rmSync(safeTmpDir, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', safeTmpDir); } catch (err) { console.warn('[teardown] Could not remove temp dir:', err); } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index ec80733..c0a8cd7 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'; export async function loginAs(page: Page, username: string, password: string): Promise { const usernameField = page - .locator('input[type="text"], input[id*="user"], input[name*="user"]') + .locator('input[autocomplete="username"], input[id*="user" i], input[name*="user" i], input[placeholder*="user" i]') .first(); const passwordField = page.locator('input[type="password"]').first(); const submitBtn = page.getByRole('button', { name: /login|sign in/i }).first(); diff --git a/tests/e2e/smoke-test-defaults.json b/tests/e2e/smoke-test-defaults.json index 298a223..2c2419e 100644 --- a/tests/e2e/smoke-test-defaults.json +++ b/tests/e2e/smoke-test-defaults.json @@ -1,6 +1,3 @@ { - "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - "adminSecret": "SmokeTestAdmin1", - "adminUsername": "testadmin", - "adminPassword": "T3stPass@9" + "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" } diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index 176da8f..d10a25d 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -3,117 +3,127 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Auth – /api/auth', () => { test('GET /api/auth/status returns enabled methods', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/auth/status'); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(typeof body).toBe('object'); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/auth/status'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); }); test('POST /api/auth/login – valid credentials return sessionId', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, + await withApi(async (api) => { + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('sessionId'); + expect(typeof body.sessionId).toBe('string'); + expect(body.sessionId.length).toBeGreaterThan(8); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('sessionId'); - expect(typeof body.sessionId).toBe('string'); - expect(body.sessionId.length).toBeGreaterThan(8); - await api.dispose(); }); test('POST /api/auth/login – wrong password returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: 'WrongPass@1' }, + await withApi(async (api) => { + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('POST /api/auth/login – unknown user returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/auth/login', { - data: { username: 'nobody', password: 'WrongPass@1' }, + await withApi(async (api) => { + const res = await api.post('/api/auth/login', { + data: { username: 'nobody', password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('GET /api/peers – no auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + }); }); test('GET /api/status – valid session returns 200', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); }); - expect(res.status()).toBe(200); - await api.dispose(); }); test('GET /api/event-log – valid API key (X-API-Key) returns 200', async () => { test.skip(!apiKey, 'No API key available'); - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey! }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey! }, + }); + expect(res.status()).toBe(200); }); - expect(res.status()).toBe(200); - await api.dispose(); }); test('GET /api/event-log – valid API key (Bearer) returns 200', async () => { test.skip(!apiKey, 'No API key available'); - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { Authorization: `Bearer ${apiKey!}` }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { Authorization: `Bearer ${apiKey!}` }, + }); + expect(res.status()).toBe(200); }); - expect(res.status()).toBe(200); - await api.dispose(); }); test('GET /api/peers – invalid API key returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers', { - headers: { 'X-API-Key': 'totally-invalid-key' }, + await withApi(async (api) => { + const res = await api.get('/api/peers', { + headers: { 'X-API-Key': 'totally-invalid-key' }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('POST /api/auth/logout – returns 200 and clears session', async () => { // Log in fresh so we don't burn the shared session - const api = await request.newContext({ baseURL: baseUrl }); - const loginRes = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, - }); - expect(loginRes.status()).toBe(200); - const { sessionId: tempSession } = await loginRes.json(); + await withApi(async (api) => { + const loginRes = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + expect(loginRes.status()).toBe(200); + const { sessionId: tempSession } = await loginRes.json(); - const logoutRes = await api.post('/api/auth/logout', { - headers: { 'X-Session-ID': tempSession }, - }); - expect(logoutRes.status()).toBe(200); + const logoutRes = await api.post('/api/auth/logout', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(logoutRes.status()).toBe(200); - // The session should now be invalid - const afterRes = await api.get('/api/peers', { - headers: { 'X-Session-ID': tempSession }, + // The session should now be invalid + const afterRes = await api.get('/api/peers', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(afterRes.status()).toBe(401); }); - expect(afterRes.status()).toBe(401); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index e864f47..cddaefb 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -3,98 +3,108 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Status – /api/status', () => { test('GET /api/status is publicly accessible without auth', async () => { // /api/status intentionally allows unauthenticated health checks - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status'); - expect(res.status()).toBe(200); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/status'); + expect(res.status()).toBe(200); + }); }); test('GET /api/status returns 200 with node info', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.serverRunning).toBe(true); - expect(body.nodeActive).toBe(true); - expect(body).toHaveProperty('health'); - expect(body).toHaveProperty('relayCount'); - expect(body).toHaveProperty('timestamp'); - await api.dispose(); + const body = await res.json(); + expect(body.serverRunning).toBe(true); + expect(body.nodeActive).toBe(true); + expect(body).toHaveProperty('health'); + expect(body).toHaveProperty('relayCount'); + expect(body).toHaveProperty('timestamp'); + }); }); test('GET /api/status has valid health object', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.health).toHaveProperty('isConnected'); + expect(typeof body.health.isConnected).toBe('boolean'); + expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.health).toHaveProperty('isConnected'); - expect(typeof body.health.isConnected).toBe('boolean'); - expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); - await api.dispose(); }); }); test.describe('Peers – /api/peers', () => { test('GET /api/peers returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + }); }); test('GET /api/peers returns peer list', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + const res = await api.get('/api/peers', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('peers'); - expect(Array.isArray(body.peers)).toBe(true); - // 2-of-2 keyset: 1 remote peer (self filtered out) - expect(body.peers.length).toBeGreaterThanOrEqual(1); - expect(typeof body.total).toBe('number'); - expect(typeof body.online).toBe('number'); - await api.dispose(); + const body = await res.json(); + expect(body).toHaveProperty('peers'); + expect(Array.isArray(body.peers)).toBe(true); + // 2-of-2 keyset: 1 remote peer (self filtered out) + expect(body.peers.length).toBeGreaterThanOrEqual(1); + expect(typeof body.total).toBe('number'); + expect(typeof body.online).toBe('number'); + }); }); test('GET /api/peers/group returns group pubkey', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers/group', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/peers/group', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(body.pubkey).toBe(state.groupPubkeyHex); + expect(typeof body.threshold).toBe('number'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(body.pubkey).toBe(state.groupPubkeyHex); - expect(typeof body.threshold).toBe('number'); - await api.dispose(); }); test('GET /api/peers/self returns own share pubkey', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers/self', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/peers/self', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(typeof body.pubkey).toBe('string'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(typeof body.pubkey).toBe('string'); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 68ed449..f801ea4 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -154,7 +154,9 @@ test.describe('Admin – Users', () => { }); expect(res.status()).toBe(200); const body = await res.json(); + expect(body).toHaveProperty('admin', true); expect(body).toHaveProperty('userId'); + expect(body.userId).not.toBeNull(); }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 0585383..2f15fc3 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -4,84 +4,94 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Event log – /api/event-log', () => { test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/event-log'); + expect(res.status()).toBe(401); + }); }); test('GET /api/event-log returns entries array', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('entries'); + expect(Array.isArray(body.entries)).toBe(true); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('entries'); - expect(Array.isArray(body.entries)).toBe(true); - await api.dispose(); }); test('entries have expected shape', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.entries)).toBe(true); + expect(body.entries.length).toBeGreaterThan(0); + const entry = body.entries[0]; + expect(entry).toHaveProperty('type'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('timestamp'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(Array.isArray(body.entries)).toBe(true); - expect(body.entries.length).toBeGreaterThan(0); - const entry = body.entries[0]; - expect(entry).toHaveProperty('type'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('timestamp'); - await api.dispose(); }); test('pagination params are accepted', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log?limit=5', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/event-log?limit=5', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.entries.length).toBeLessThanOrEqual(5); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.entries.length).toBeLessThanOrEqual(5); - await api.dispose(); }); test('GET /api/event-log/export returns NDJSON', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log/export', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + const res = await api.get('/api/event-log/export', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); - // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array - const contentType = res.headers()['content-type'] ?? ''; - expect(contentType).toContain('ndjson'); + // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array + const contentType = res.headers()['content-type'] ?? ''; + expect(contentType).toContain('ndjson'); - const text = await res.text(); - // Each non-empty line must be valid JSON - const lines = text.trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); - } - await api.dispose(); + const text = await res.text(); + // Each non-empty line must be valid JSON + const lines = text.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); }); test('export without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log/export'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/event-log/export'); + expect(res.status()).toBe(401); + }); }); }); diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts index 8879bfe..51bc8f4 100644 --- a/tests/e2e/specs/07-env.e2e.ts +++ b/tests/e2e/specs/07-env.e2e.ts @@ -7,94 +7,104 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Env / credentials – /api/env', () => { test('GET /api/env returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/env'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/env'); + expect(res.status()).toBe(401); + }); }); test('GET /api/env with session returns credential metadata', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/env', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/env', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + // In DB mode the response should indicate that credentials are present + expect(body).toHaveProperty('hasCredentials', true); }); - expect(res.status()).toBe(200); - const body = await res.json(); - // In DB mode the response should indicate that credentials are present - expect(body).toHaveProperty('hasCredentials', true); - await api.dispose(); }); test('POST /api/env – invalid GROUP_CRED returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: 'not-a-valid-bfgroup-credential', - SHARE_CRED: state.shareCredentials[0], - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: 'not-a-valid-bfgroup-credential', + SHARE_CRED: state.shareCredentials[0], + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env – invalid SHARE_CRED returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: 'not-a-valid-bfshare-credential', - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: 'not-a-valid-bfshare-credential', + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env – invalid relay URL returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: ['not-a-websocket-url'], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: ['not-a-websocket-url'], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env – empty RELAYS returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: [], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: [], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 5e00398..200fcd6 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -48,6 +48,9 @@ test.describe('UI – Authenticated app', () => { // The Signer tab or its content should be visible after login const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); await expect(signerTab).toBeVisible({ timeout: 8_000 }); + await signerTab.click(); + await expect(page.locator('body')).toContainText(/server signer:\s*(running|starting|stopped)/i, { timeout: 8_000 }); + await expect(page.locator('body')).toContainText(/\bnode\s+(active|inactive)\b/i, { timeout: 8_000 }); }); test('Configure tab is accessible', async ({ page }) => { @@ -104,6 +107,7 @@ test.describe('UI – Onboarding already completed', () => { test('/ does not show onboarding when DB is initialised', async ({ page }) => { await page.goto(baseUrl); // The onboarding "ADMIN_SECRET" or "setup" copy should NOT appear - await expect(page.locator('body')).not.toContainText('Admin Secret', { timeout: 6_000 }); + await expect(page.locator('body')).not.toContainText(/\badmin[\s_-]*secret\b/i, { timeout: 6_000 }); + await expect(page.locator('body')).not.toContainText(/\bset[\s_-]*up\b/i, { timeout: 6_000 }); }); }); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index a05698d..d690518 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -34,7 +34,7 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ groupCredential: z.string().min(1, 'groupCredential must be non-empty'), shareCredentials: z.array(z.string().min(1, 'share credential must be non-empty')), groupPubkeyHex: z.string(), - adminUsername: z.string(), + adminUsername: z.string().min(1, 'adminUsername must be non-empty'), adminPassword: z.string().min(1, 'adminPassword must be non-empty'), adminSecret: z.string().min(1, 'adminSecret must be non-empty'), }); diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 654f20d..dc00aea 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,9 +1,30 @@ import { describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +function loadFixtureTestKeysetSecret(): string | undefined { + const fixturePath = path.resolve('tests/e2e/smoke-test-defaults.json'); + if (!fs.existsSync(fixturePath)) return undefined; + try { + const raw: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + if (typeof raw !== 'object' || raw === null) return undefined; + const testNsecHex = (raw as Record).testNsecHex; + return typeof testNsecHex === 'string' && testNsecHex.trim().length > 0 ? testNsecHex : undefined; + } catch { + return undefined; + } +} + const TEST_KEYSET_SECRET = process.env.TEST_KEYSET_SECRET ?? - 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + process.env.TEST_NSEC_HEX ?? + loadFixtureTestKeysetSecret(); +if (!TEST_KEYSET_SECRET) { + throw new Error( + 'TEST_KEYSET_SECRET (or TEST_NSEC_HEX) must be set, or tests/e2e/smoke-test-defaults.json must provide testNsecHex.' + ); +} describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts new file mode 100644 index 0000000..f2856c2 --- /dev/null +++ b/tests/routes/helpers/script-runner.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildScriptEnv, + ISOLATED_ENV_KEYS, + ISOLATED_ENV_PREFIXES, +} from './script-runner'; + +describe('buildScriptEnv', () => { + test('keeps forced values and blocks reserved override keys', () => { + const env = buildScriptEnv( + { + NODE_ENV: 'production', + DB_PATH: '/tmp/attacker.db', + ENV_FILE_PATH: '/tmp/attacker.env', + ADMIN_SECRET: 'nope', + CUSTOM_FLAG: '1', + }, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + + expect(env.NODE_ENV).toBe('test'); + expect(env.DB_PATH).toBe('/tmp/forced.db'); + expect(env.ENV_FILE_PATH).toBe('/tmp/forced.env'); + expect(env.ADMIN_SECRET).toBeUndefined(); + expect(env.CUSTOM_FLAG).toBe('1'); + }); + + test('blocks override keys that match isolated prefixes', () => { + const reservedPrefix = ISOLATED_ENV_PREFIXES[0]; + const env = buildScriptEnv( + { + [`${reservedPrefix}WINDOW`]: '123', + [`${reservedPrefix}MAX`]: '999', + SAFE_KEY: 'ok', + }, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + + expect(env[`${reservedPrefix}WINDOW`]).toBeUndefined(); + expect(env[`${reservedPrefix}MAX`]).toBeUndefined(); + expect(env.SAFE_KEY).toBe('ok'); + }); + + test('removes reserved keys inherited from process.env', () => { + const preserved = process.env[ISOLATED_ENV_KEYS[0]]; + process.env[ISOLATED_ENV_KEYS[0]] = 'should-not-leak'; + try { + const env = buildScriptEnv( + {}, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + expect(env[ISOLATED_ENV_KEYS[0]]).toBe('test'); + } finally { + if (preserved === undefined) delete process.env[ISOLATED_ENV_KEYS[0]]; + else process.env[ISOLATED_ENV_KEYS[0]] = preserved; + } + }); +}); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 1d23378..8d333c7 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from 'url'; export const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; -const ISOLATED_ENV_KEYS = [ +export const ISOLATED_ENV_KEYS = [ 'NODE_ENV', 'HEADLESS', 'AUTH_ENABLED', @@ -27,11 +27,28 @@ const ISOLATED_ENV_KEYS = [ 'ENV_FILE_PATH', ] as const; -const ISOLATED_ENV_PREFIXES = [ +export const ISOLATED_ENV_PREFIXES = [ 'RATE_LIMIT_', ] as const; -function buildScriptEnv(overrides: Record): Record { +function isBlockedEnvKey(key: string): boolean { + return ISOLATED_ENV_KEYS.includes(key as (typeof ISOLATED_ENV_KEYS)[number]) || + ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); +} + +function sanitizeOverrides(overrides: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (isBlockedEnvKey(key)) continue; + sanitized[key] = value; + } + return sanitized; +} + +export function buildScriptEnv( + overrides: Record, + forced: { dbPath: string; envFilePath: string } +): Record { const nextEnv: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (typeof value === 'string') { @@ -39,37 +56,35 @@ function buildScriptEnv(overrides: Record): Record key.startsWith(prefix))) { + if (isBlockedEnvKey(key)) { delete nextEnv[key]; } } + const sanitizedOverrides = sanitizeOverrides(overrides); + return { ...nextEnv, + ...sanitizedOverrides, NODE_ENV: 'test', - ...overrides, + DB_PATH: forced.dbPath, + ENV_FILE_PATH: forced.envFilePath, }; } /** * Runs route code in an isolated Bun subprocess and returns the parsed @@RESULT@@ JSON payload. - * Uses T=any by default so callers without an explicit type can access result properties (e.g. out.status). */ -export function runRouteScript(code: string, env: Record = {}): T { +export function runRouteScript>(code: string, env: Record = {}): T { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); writeFileSync(runner, code, 'utf8'); - const isolatedEnv = buildScriptEnv({ - ENV_FILE_PATH: path.join(tmpDir, '.env'), - DB_PATH: path.join(tmpDir, 'igloo.db'), - ...env + const isolatedEnv = buildScriptEnv(env, { + envFilePath: path.join(tmpDir, '.env'), + dbPath: path.join(tmpDir, 'igloo.db'), }); const result = Bun.spawnSync({ From 944ec63ecc6d041e94a075f79042cffe72c9bf4a Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 08:58:41 -0600 Subject: [PATCH 13/32] fix: harden smoke test harness and teardown behavior --- frontend/components/ui/peer-list.tsx | 2 +- src/class/relay.test.ts | 31 +++++++++++++++------- src/routes/utils.ts | 2 -- tests/e2e/cosigner.mjs | 26 +++++++++++++++++- tests/e2e/global-teardown.ts | 12 ++++++--- tests/e2e/specs/08-ui.e2e.ts | 6 ++--- tests/routes/env.db-mode.spec.ts | 8 +++++- tests/routes/helpers/script-runner.spec.ts | 11 ++++---- 8 files changed, 72 insertions(+), 26 deletions(-) diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 192a01b..df9a180 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -394,7 +394,7 @@ const PeerList: React.FC = ({ window.removeEventListener('peerStatusUpdate', handlePeerUpdate as EventListener); window.removeEventListener('peerPingUpdate', handlePeerUpdate as EventListener); }; - }, [authHeaders, isSignerRunning]); + }, [isSignerRunning]); // Ping individual peer const handlePingPeer = useCallback(async (peerPubkey: string) => { diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index 6d716cb..cb04e83 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -2,13 +2,20 @@ import { describe, expect, it } from 'bun:test'; import { NostrRelay } from './relay.js'; type FakeSocket = { - data: any; + data: unknown; sent: string[]; closed: boolean; send: (message: string) => void; close: () => void; }; +type RelayHandler = ReturnType; +type HandlerSocket = Parameters>[0]; + +function asHandlerSocket(socket: FakeSocket): HandlerSocket { + return socket as unknown as HandlerSocket; +} + function createFakeSocket(): FakeSocket { return { data: null, @@ -31,10 +38,11 @@ describe('NostrRelay REQ handling', () => { it('normalizes wrapped nostr-tools REQ filter arrays into subscriptions', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); const handler = relay.handler(); - handler.open?.(socket as any); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); expect(relay.subs.size).toBe(1); const [sub] = Array.from(relay.subs.values()); @@ -49,10 +57,11 @@ describe('NostrRelay REQ handling', () => { it('rejects REQ with an empty wrapped filter array', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); const handler = relay.handler(); - handler.open?.(socket as any); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-empty', []])); + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-empty', []])); expect(relay.subs.size).toBe(0); const messages = decodeSent(socket); @@ -62,19 +71,21 @@ describe('NostrRelay REQ handling', () => { it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); const handler = relay.handler(); - handler.open?.(socket as any); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); expect(relay.subs.size).toBe(1); - handler.message?.(socket as any, JSON.stringify(['CLOSE', 'sub-close'])); + handler.message?.(ws, JSON.stringify(['CLOSE', 'sub-close'])); expect(relay.subs.size).toBe(0); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); expect(relay.subs.size).toBe(1); - handler.close?.(socket as any, 1000, 'test'); + const closeHandler = handler.close as unknown as ((socketArg: HandlerSocket, code: number) => void) | undefined; + closeHandler?.(ws, 1000); expect(relay.subs.size).toBe(0); expect(socket.closed).toBe(true); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index a54206b..d277bd4 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -52,8 +52,6 @@ function extractIpv4MappedIpv6(hostname: string): string | null { const mappedPrefixes = [ /^::ffff:(.+)$/i, /^::ffff:0:(.+)$/i, - /^0:0:0:0:0:ffff:(.+)$/i, - /^0:0:0:0:ffff:0:(.+)$/i, ]; for (const pattern of mappedPrefixes) { const match = hostname.match(pattern); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 6b11c32..3bae33f 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -16,6 +16,30 @@ const { connectNode, } = await import('@frostr/igloo-core'); +const CONNECT_TIMEOUT_MS_RAW = process.env.SMOKE_COSIGNER_CONNECT_TIMEOUT_MS ?? '20000'; +const parsedConnectTimeout = Number.parseInt(CONNECT_TIMEOUT_MS_RAW, 10); +const CONNECT_TIMEOUT_MS = Number.isFinite(parsedConnectTimeout) && parsedConnectTimeout > 0 + ? parsedConnectTimeout + : 20000; + +async function connectWithTimeout(nodeInstance, relay) { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`connectNode timeout after ${CONNECT_TIMEOUT_MS}ms for relay ${relay}`)); + }, CONNECT_TIMEOUT_MS); + }); + + const connectionPromise = connectNode(nodeInstance); + connectionPromise.catch(() => {}); + + try { + await Promise.race([connectionPromise, timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + /** * Serializes a value to JSON, replacing circular refs with "[Circular]" to avoid * "Converting circular structure to JSON" TypeError from bubbling into outer catch. @@ -64,7 +88,7 @@ try { node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', safeStringify(a).slice(0, 100))); console.log('[cosigner] Connecting to relay:', relayUrl); - await connectNode(node); + await connectWithTimeout(node, relayUrl); console.log('[cosigner] Connected. Pubkey:', node.pubkey); const filter = node.client?.filter; const privateFilter = node.client?._filter; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index d5ea286..66347f0 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -81,14 +81,15 @@ export default async function globalTeardown(_config: FullConfig): Promise throw new Error(`[teardown] State file does not exist: ${resolvedStateFile}`); } + let skipTempCleanup = false; try { const ageMs = Date.now() - fs.statSync(resolvedStateFile).mtimeMs; if (ageMs > MAX_STATE_AGE_MS) { console.warn( `[teardown] State file is stale (${Math.round(ageMs / 1000)}s old); ` + - 'skipping process kill and temp cleanup to avoid affecting unrelated runs.' + 'skipping temp cleanup, but continuing process teardown to avoid leaks.' ); - return; + skipTempCleanup = true; } } catch (error) { console.warn('[teardown] Could not stat state file; skipping cleanup for safety:', error); @@ -105,7 +106,7 @@ export default async function globalTeardown(_config: FullConfig): Promise const cosignerPid = parsePositivePid(parsedState.cosignerPid, 'cosignerPid'); const serverPid = parsePositivePid(parsedState.serverPid, 'serverPid'); - const safeTmpDir = resolveSafeTmpDir(parsedState.tmpDir); + const safeTmpDir = skipTempCleanup ? null : resolveSafeTmpDir(parsedState.tmpDir); for (const [label, pid] of [['co-signer', cosignerPid], ['server', serverPid]] as const) { if (!pid) continue; @@ -125,6 +126,11 @@ export default async function globalTeardown(_config: FullConfig): Promise await new Promise(r => setTimeout(r, 500)); + if (skipTempCleanup) { + console.warn('[teardown] Skipping temp dir removal because state file is stale.'); + return; + } + if (!safeTmpDir) { console.warn('[teardown] Skipping temp dir removal due to invalid tmpDir state.'); return; diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 200fcd6..54f0181 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -106,8 +106,8 @@ test.describe('UI – Authenticated app', () => { test.describe('UI – Onboarding already completed', () => { test('/ does not show onboarding when DB is initialised', async ({ page }) => { await page.goto(baseUrl); - // The onboarding "ADMIN_SECRET" or "setup" copy should NOT appear - await expect(page.locator('body')).not.toContainText(/\badmin[\s_-]*secret\b/i, { timeout: 6_000 }); - await expect(page.locator('body')).not.toContainText(/\bset[\s_-]*up\b/i, { timeout: 6_000 }); + // Specific onboarding copy should not appear once DB is initialized. + await expect(page.locator('body')).not.toContainText(/enter the admin secret to begin setting up your igloo server/i, { timeout: 6_000 }); + await expect(page.locator('body')).not.toContainText(/create your admin account to secure your igloo server/i, { timeout: 6_000 }); }); }); diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index dc00aea..7310f76 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,10 +1,16 @@ import { describe, expect, test } from 'bun:test'; import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; function loadFixtureTestKeysetSecret(): string | undefined { - const fixturePath = path.resolve('tests/e2e/smoke-test-defaults.json'); + const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'e2e', + 'smoke-test-defaults.json' + ); if (!fs.existsSync(fixturePath)) return undefined; try { const raw: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts index f2856c2..27e546f 100644 --- a/tests/routes/helpers/script-runner.spec.ts +++ b/tests/routes/helpers/script-runner.spec.ts @@ -48,8 +48,9 @@ describe('buildScriptEnv', () => { }); test('removes reserved keys inherited from process.env', () => { - const preserved = process.env[ISOLATED_ENV_KEYS[0]]; - process.env[ISOLATED_ENV_KEYS[0]] = 'should-not-leak'; + const reservedKey = ISOLATED_ENV_KEYS[0]; + const preserved = process.env[reservedKey]; + process.env[reservedKey] = 'should-not-leak'; try { const env = buildScriptEnv( {}, @@ -58,10 +59,10 @@ describe('buildScriptEnv', () => { envFilePath: '/tmp/forced.env', } ); - expect(env[ISOLATED_ENV_KEYS[0]]).toBe('test'); + expect(env[reservedKey]).toBe('test'); } finally { - if (preserved === undefined) delete process.env[ISOLATED_ENV_KEYS[0]]; - else process.env[ISOLATED_ENV_KEYS[0]] = preserved; + if (preserved === undefined) delete process.env[reservedKey]; + else process.env[reservedKey] = preserved; } }); }); From 557e4a5b593fca00684053319096ef1784560e03 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 11:13:11 -0600 Subject: [PATCH 14/32] fix: tighten e2e cleanup and env test safeguards --- .github/workflows/ci.yml | 3 +- src/class/relay.ts | 2 +- src/routes/env.ts | 2 +- src/routes/utils.test.ts | 6 +++ tests/e2e/global-setup.ts | 11 +++++ tests/e2e/specs/05-admin.e2e.ts | 57 +++++++++++++--------- tests/routes/helpers/script-runner.spec.ts | 6 ++- 7 files changed, 59 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e863b1..8da2558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: audit_output="$(cat "$audit_log")" echo "bun audit failed after ${last_attempt:-3} attempts with exit code ${last_exit}" - if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' "$audit_log"; then + if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' <<< "$audit_output"; then echo "bun audit failed after retries due to network/registry error: $audit_output" else echo "bun audit failed after retries - vulnerabilities detected: $audit_output" @@ -114,6 +114,7 @@ jobs: - name: Check for secrets # Pinned to immutable commit (v3.93.4) for supply-chain safety. + # Maintenance: periodically verify this SHA still corresponds to the intended upstream release. uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ diff --git a/src/class/relay.ts b/src/class/relay.ts index 2cf6914..e6a518e 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -201,7 +201,7 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.info(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) + this.log.debug(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } diff --git a/src/routes/env.ts b/src/routes/env.ts index a34899f..20667f4 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -402,7 +402,7 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (updatingCredentials) { // Set the timestamp explicitly here to avoid relying on downstream helpers // for correctness, then perform a single write. - (env as any).CREDENTIALS_SAVED_AT = new Date().toISOString(); + env.CREDENTIALS_SAVED_AT = new Date().toISOString(); } const writeOk = await writeEnvFile(env); diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index a7b4d6a..f836a77 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -62,6 +62,12 @@ describe('getValidRelays', () => { }); }); + it('filters IPv4-mapped IPv6 ::ffff:0: relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:0:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 7d58bd6..4c97003 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -279,6 +279,17 @@ export default async function globalSetup(_config: FullConfig): Promise { const { generateKeysetWithSecret, decodeGroup } = iglooCore; const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); + if ( + !Array.isArray(shareCredentials) || + shareCredentials.length < 2 || + typeof shareCredentials[0] !== 'string' || + typeof shareCredentials[1] !== 'string' + ) { + throw new Error( + `Invalid keyset from generateKeysetWithSecret: shareCredentials.length=${Array.isArray(shareCredentials) ? shareCredentials.length : 'non-array'} ` + + `shareCredentials=${JSON.stringify(shareCredentials)} groupCredentialType=${typeof groupCredential}` + ); + } const group = decodeGroup(groupCredential); const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); state.groupCredential = groupCredential; diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index f801ea4..8a51568 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -93,32 +93,43 @@ test.describe('Admin – API keys', () => { test('revoked API key returns 401', async () => { await withApi(async (api) => { - // Create a fresh key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'revoke-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); + let createdApiKey: { id: string | number; token: string } | null = null; + try { + // Create a fresh key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'revoke-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + createdApiKey = apiKey; - // Verify it works on an auth-protected endpoint - const beforeRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(beforeRes.status()).toBe(200); + // Verify it works on an auth-protected endpoint + const beforeRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': createdApiKey.token }, + }); + expect(beforeRes.status()).toBe(200); - // Revoke it - const revokeRes = await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, - }); - expect(revokeRes.status()).toBe(200); + // Revoke it + const revokeRes = await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdApiKey.id, reason: 'smoke-test cleanup' }, + }); + expect(revokeRes.status()).toBe(200); - // Now the revoked key should be rejected - const afterRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(afterRes.status()).toBe(401); + // Now the revoked key should be rejected + const afterRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': createdApiKey.token }, + }); + expect(afterRes.status()).toBe(401); + } finally { + if (createdApiKey) { + await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdApiKey.id, reason: 'test cleanup' }, + }).catch(() => null); + } + } }); }); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts index 27e546f..acae6df 100644 --- a/tests/routes/helpers/script-runner.spec.ts +++ b/tests/routes/helpers/script-runner.spec.ts @@ -48,7 +48,9 @@ describe('buildScriptEnv', () => { }); test('removes reserved keys inherited from process.env', () => { - const reservedKey = ISOLATED_ENV_KEYS[0]; + const reservedKey = ISOLATED_ENV_KEYS.find((key) => key !== 'NODE_ENV'); + expect(reservedKey).toBeDefined(); + if (!reservedKey) throw new Error('Expected at least one reserved key other than NODE_ENV'); const preserved = process.env[reservedKey]; process.env[reservedKey] = 'should-not-leak'; try { @@ -59,7 +61,7 @@ describe('buildScriptEnv', () => { envFilePath: '/tmp/forced.env', } ); - expect(env[reservedKey]).toBe('test'); + expect(env[reservedKey]).toBeUndefined(); } finally { if (preserved === undefined) delete process.env[reservedKey]; else process.env[reservedKey] = preserved; From 816eb0fe35e5f648e45f05a656a28aa5174e0a83 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 13:30:21 -0600 Subject: [PATCH 15/32] fix: pin Bun version in docker and CI workflows --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- Dockerfile | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8da2558..ec46391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -57,7 +57,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -77,7 +77,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf3579b..a42ac50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/Dockerfile b/Dockerfile index 65fae40..0052609 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for smaller production image -FROM oven/bun:latest AS build +FROM oven/bun:1.3.3 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # --- Production stage --- -FROM oven/bun:latest AS production +FROM oven/bun:1.3.3 AS production WORKDIR /app From 5f8713bb180bc6637c124e0feda29df796c36e28 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 15:26:02 -0600 Subject: [PATCH 16/32] fix: harden e2e smoke tests and peer-list collapse behavior --- frontend/components/ui/peer-list.tsx | 20 ++++++++++++++++++-- llm/implementation/e2e-smoke-tests.md | 3 ++- src/class/relay.test.ts | 21 +++++++++++++++++++++ src/routes/utils.test.ts | 12 ++++++++++++ src/routes/utils.ts | 2 +- tests/e2e/global-setup.ts | 5 +++++ tests/e2e/specs/05-admin.e2e.ts | 3 ++- tests/e2e/specs/06-event-log.e2e.ts | 12 ++++++++++++ tests/e2e/state.ts | 7 ++++++- tests/routes/helpers/script-runner.spec.ts | 3 ++- 10 files changed, 81 insertions(+), 7 deletions(-) diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index df9a180..f8c43ec 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -166,6 +166,7 @@ const PeerList: React.FC = ({ defaultExpanded = false }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [shouldRenderContent, setShouldRenderContent] = useState(defaultExpanded); const [peers, setPeers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -184,6 +185,19 @@ const PeerList: React.FC = ({ } }, [defaultExpanded]); + useEffect(() => { + if (isExpanded) { + setShouldRenderContent(true); + } + }, [isExpanded]); + + const handleCollapseTransitionEnd = useCallback((event: React.TransitionEvent) => { + if (event.target !== event.currentTarget) return; + if (!isExpanded) { + setShouldRenderContent(false); + } + }, [isExpanded]); + const setPolicyBusyState = useCallback((key: string, busy: boolean) => { setPolicySavingPeers(prev => { const next = new Set(prev); @@ -348,7 +362,7 @@ const PeerList: React.FC = ({ return () => { isActive = false; }; - }, [isSignerRunning, groupCredential, shareCredential, disabled, fetchSelfPubkey, fetchPeers]); + }, [isSignerRunning, groupCredential, shareCredential, disabled, fetchSelfPubkey, fetchPeers, authHeaders]); // Unified handler for peer status and ping updates const handlePeerUpdate = (event: CustomEvent) => { @@ -683,8 +697,10 @@ const PeerList: React.FC = ({ isExpanded ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0" )} aria-hidden={!isExpanded} + inert={!isExpanded} + onTransitionEnd={handleCollapseTransitionEnd} > - {isExpanded && ( + {shouldRenderContent && (
{isLoading ? (
diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index e75507f..0bb18d7 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -33,6 +33,7 @@ Prerequisites: - `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). - `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). - Keep port 18002 free when possible. `tests/e2e/global-setup.ts` calls `resolvePort()` and will usually fall back to a random free port if 18002 is busy, but hard-coded references can still break if the preferred port is unavailable. +- Admin credentials must be provided before running tests. Set `SMOKE_ADMIN_SECRET`, `SMOKE_ADMIN_USERNAME`, and `SMOKE_ADMIN_PASSWORD`, or provide `tests/e2e/smoke-test.local.json`; otherwise `tests/e2e/global-setup.ts` exits early. ## File Structure @@ -75,7 +76,7 @@ The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, l |---|---|---| | `HOST_PORT` | `18002` | Fixed test port | | `HOST_NAME` | `127.0.0.1` | Loopback only | -| `ADMIN_SECRET` | `SmokeTestAdmin1` | Deterministic | +| `ADMIN_SECRET` | from env/fixture (e.g. `$SMOKE_ADMIN_SECRET`) | Loaded from environment or local fixture — do not commit secrets | | `DB_PATH` | `$TMPDIR/igloo-smoke-test/db` | Fresh DB per run | | `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | | `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index cb04e83..5805262 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -68,6 +68,27 @@ describe('NostrRelay REQ handling', () => { expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); }); + it('accepts canonical multi-filter REQ payloads and creates a subscription', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + const authorHex = 'f'.repeat(64); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-multi', { kinds: [1] }, { authors: [authorHex] }])); + + expect(relay.subs.size).toBe(1); + const [sub] = Array.from(relay.subs.values()); + expect(sub?.sub_id).toBe('sub-multi'); + expect(sub?.filters).toHaveLength(2); + expect((sub?.filters[0] as { kinds?: number[] }).kinds).toEqual([1]); + expect((sub?.filters[1] as { authors?: string[] }).authors).toEqual([authorHex]); + + const messages = decodeSent(socket); + expect(messages).toContainEqual(['EOSE', 'sub-multi']); + }); + it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index f836a77..b9ab366 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -68,6 +68,18 @@ describe('getValidRelays', () => { }); }); + it('filters expanded IPv6 loopback relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[0:0:0:0:0:0:0:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters expanded IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[0:0:0:0:0:ffff:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) diff --git a/src/routes/utils.ts b/src/routes/utils.ts index d277bd4..4f84237 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -64,7 +64,7 @@ function extractIpv4MappedIpv6(hostname: string): string | null { function isLoopbackRelayHost(hostname: string): boolean { let normalized = hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); - if (normalized === 'localhost' || normalized === '::1') return true; + if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; const mappedIpv4 = extractIpv4MappedIpv6(normalized); if (mappedIpv4) { diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 4c97003..c5347a2 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -162,6 +162,11 @@ async function resolvePort(host: string, preferredPort: number): Promise return preferredPort; } const fallbackPort = await reserveRandomPort(host); + if (!Number.isInteger(fallbackPort) || fallbackPort < 1 || fallbackPort > 65535) { + throw new Error( + `[setup] Failed to reserve a valid fallback port after preferred port ${preferredPort} was busy (got: ${fallbackPort})`, + ); + } console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort} (probe-close race still applies)`); return fallbackPort; } diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 8a51568..5785ceb 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -7,8 +7,9 @@ import { test, expect, request } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, adminUsername } = state; async function withApi(fn: (api: APIRequestContext) => Promise): Promise { diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 2f15fc3..aaf4f26 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -21,6 +21,18 @@ async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + test.beforeAll(async () => { + await withApi(async (api) => { + const seedRes = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'b'.repeat(64) }, + }); + if (!seedRes.ok()) { + throw new Error(`Failed to seed event log via /api/sign: ${seedRes.status()} ${await seedRes.text()}`); + } + }); + }); + test('returns 401 without auth', async () => { await withApi(async (api) => { const res = await api.get('/api/event-log'); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index d690518..a27f818 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -64,7 +64,12 @@ const STUB: SmokeTestState = { */ export function loadState(): SmokeTestState { const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile) return STUB; + if (!stateFile) { + return { + ...STUB, + shareCredentials: [...STUB.shareCredentials], + }; + } try { const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); const result = SMOKE_TEST_STATE_SCHEMA.safeParse(parsed); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts index acae6df..b14f9c4 100644 --- a/tests/routes/helpers/script-runner.spec.ts +++ b/tests/routes/helpers/script-runner.spec.ts @@ -48,7 +48,8 @@ describe('buildScriptEnv', () => { }); test('removes reserved keys inherited from process.env', () => { - const reservedKey = ISOLATED_ENV_KEYS.find((key) => key !== 'NODE_ENV'); + const forcedKeys = new Set(['NODE_ENV', 'DB_PATH', 'ENV_FILE_PATH']); + const reservedKey = ISOLATED_ENV_KEYS.find((key) => !forcedKeys.has(key)); expect(reservedKey).toBeDefined(); if (!reservedKey) throw new Error('Expected at least one reserved key other than NODE_ENV'); const preserved = process.env[reservedKey]; From 3f6481f35ee201218ac6bbc125737d644d0e7748 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 16:48:33 -0600 Subject: [PATCH 17/32] fix: harden relay limits and teardown safety --- playwright.config.ts | 5 +-- src/class/relay.test.ts | 49 +++++++++++++++++++++++++++ src/class/relay.ts | 7 ++-- tests/e2e/cosigner.mjs | 4 ++- tests/e2e/global-teardown.ts | 35 +++++++++++++++++++ tests/routes/helpers/script-runner.ts | 22 ++++++++++-- 6 files changed, 114 insertions(+), 8 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 42fdb5b..97b856f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,10 +24,11 @@ export default defineConfig({ }, projects: [ - // Pure API specs (01–07) – use request context only, no browser + // Pure API specs – use request context only, no browser { name: 'api', - testMatch: ['**/0[1-7]-*.e2e.ts'], + testMatch: ['**/[0-9][0-9]-*.e2e.ts'], + testIgnore: ['**/08-ui.e2e.ts'], }, // Browser UI spec (08) – needs a real browser { diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index 5805262..2c9db2a 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -110,4 +110,53 @@ describe('NostrRelay REQ handling', () => { expect(relay.subs.size).toBe(0); expect(socket.closed).toBe(true); }); + + it('applies filter.limit to matched events only', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + const unmatched = { + id: 'u'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 1, + kind: 9, + tags: [], + content: '', + sig: 'b'.repeat(128), + } as Parameters[0]; + const matchedA = { + id: 'c'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 2, + kind: 1, + tags: [], + content: '', + sig: 'd'.repeat(128), + } as Parameters[0]; + const matchedB = { + id: 'e'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 3, + kind: 1, + tags: [], + content: '', + sig: 'f'.repeat(128), + } as Parameters[0]; + + relay.store(unmatched); + relay.store(matchedA); + relay.store(matchedB); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-limit', { kinds: [1], limit: 1 }])); + + const messages = decodeSent(socket); + const eventMessages = messages.filter((msg) => msg[0] === 'EVENT'); + expect(eventMessages).toHaveLength(1); + expect(eventMessages[0]?.[1]).toBe('sub-limit'); + expect((eventMessages[0]?.[2] as { kind?: number }).kind).toBe(1); + expect(messages).toContainEqual(['EOSE', 'sub-limit']); + }); }); diff --git a/src/class/relay.ts b/src/class/relay.ts index e6a518e..b7f2904 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -241,9 +241,12 @@ class RelaySession { this.send(['EVENT', sub_id, event]) this.log.client(`event matched in cache: ${event.id}`) this.log.client(`event matched subscription: ${sub_id}`) + // Decrement only when we actually sent a matching event. + if (limit_count !== undefined) { + limit_count -= 1 + if (limit_count === 0) break + } } - // Update the limit count. - if (limit_count !== undefined) limit_count -= 1 } } } diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 3bae33f..53cf269 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -103,7 +103,9 @@ try { } } catch (err) { - console.error('[cosigner] Failed to start:', err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''; + console.error(`[cosigner] Failed to start: ${message}${stack}`); process.exit(2); } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 66347f0..071cf3b 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -6,6 +6,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { execFileSync } from 'child_process'; import type { FullConfig } from '@playwright/test'; import type { SmokeTestState } from './state.js'; @@ -31,6 +32,37 @@ function isProcessRunning(pid: number): boolean { } } +function getProcessCommand(pid: number): string | null { + try { + const output = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const cmd = output.trim(); + return cmd.length > 0 ? cmd : null; + } catch { + return null; + } +} + +function validateProcessIdentity(pid: number, label: 'co-signer' | 'server'): boolean { + const command = getProcessCommand(pid); + if (!command) { + console.warn(`[teardown] Could not read command line for ${label} pid ${pid}; skipping SIGTERM for safety.`); + return false; + } + + const expectedFragment = label === 'co-signer' ? 'tests/e2e/cosigner.mjs' : 'src/server.ts'; + const matches = command.includes(expectedFragment); + if (!matches) { + console.warn( + `[teardown] ${label} pid ${pid} command did not match expected identity (${expectedFragment}); ` + + `actual="${command}". Skipping SIGTERM for safety.` + ); + } + return matches; +} + function resolveSafeTmpDir(rawTmpDir: unknown): string | null { if (typeof rawTmpDir !== 'string' || rawTmpDir.trim().length === 0) { console.warn('[teardown] Invalid tmpDir in state; skipping temp cleanup.'); @@ -114,6 +146,9 @@ export default async function globalTeardown(_config: FullConfig): Promise console.warn(`[teardown] ${label} pid ${pid} is not running; skipping SIGTERM.`); continue; } + if (!validateProcessIdentity(pid, label)) { + continue; + } try { process.kill(pid, 'SIGTERM'); console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 8d333c7..6ba153a 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -31,11 +31,22 @@ export const ISOLATED_ENV_PREFIXES = [ 'RATE_LIMIT_', ] as const; +const ERROR_PREVIEW_MAX_CHARS = 200; + function isBlockedEnvKey(key: string): boolean { return ISOLATED_ENV_KEYS.includes(key as (typeof ISOLATED_ENV_KEYS)[number]) || ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); } +function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string { + const compact = raw.replace(/\s+/g, ' ').trim(); + if (!compact) return '(empty)'; + const redacted = compact + .replace(/(admin_secret|session_secret|password|api[_-]?key|token)\s*[:=]\s*["']?[^"'\s]+/ig, '$1=') + .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); + return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...(truncated)` : redacted; +} + function sanitizeOverrides(overrides: Record): Record { const sanitized: Record = {}; for (const [key, value] of Object.entries(overrides)) { @@ -97,8 +108,10 @@ export function runRouteScript>(code: string, env: R }); if (result.exitCode !== 0) { + const stderrPreview = toSafePreview(result.stderr.toString()); + const stdoutPreview = toSafePreview(result.stdout.toString()); throw new Error( - `route script failed: status=${result.exitCode} stderr="${result.stderr.toString()}" stdout="${result.stdout.toString()}"` + `route script failed: status=${result.exitCode} stderr_preview="${stderrPreview}" stdout_preview="${stdoutPreview}"` ); } @@ -106,7 +119,7 @@ export function runRouteScript>(code: string, env: R const stdout = result.stdout.toString().trim(); const line = stdout.split('\n').reverse().find(l => l.includes(marker)); if (!line) { - throw new Error(`route script missing result marker: ${stdout}`); + throw new Error(`route script missing result marker; stdout_preview="${toSafePreview(stdout)}"`); } const rawJson = line.slice(line.indexOf(marker) + marker.length); try { @@ -114,7 +127,10 @@ export function runRouteScript>(code: string, env: R return parsed as T; } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`route script returned invalid JSON marker payload: ${detail}; raw="${rawJson}"; stdout="${stdout}"`); + throw new Error( + `route script returned invalid JSON marker payload: ${detail}; ` + + `raw_preview="${toSafePreview(rawJson)}"; stdout_preview="${toSafePreview(stdout)}"` + ); } } finally { rmSync(tmpDir, { recursive: true, force: true }); From b0681047bd7f6ba263a48d4683d1a8d6c06da13f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 17:54:51 -0600 Subject: [PATCH 18/32] fix: tighten e2e ui and loopback validation --- frontend/components/ui/peer-list.tsx | 19 ++++++++++++------- frontend/components/ui/tooltip.tsx | 3 +++ src/routes/utils.test.ts | 6 ++++++ src/routes/utils.ts | 2 +- tests/e2e/cosigner.mjs | 8 ++------ tests/e2e/specs/06-event-log.e2e.ts | 2 +- tests/e2e/specs/08-ui.e2e.ts | 4 ++++ tests/e2e/state.ts | 9 +++++++-- tests/routes/env.db-mode.spec.ts | 10 ++++++++-- 9 files changed, 44 insertions(+), 19 deletions(-) diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index f8c43ec..7a1471f 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -178,6 +178,7 @@ const PeerList: React.FC = ({ const [policySavingPeers, setPolicySavingPeers] = useState>(new Set()); const [policyPeerErrors, setPolicyPeerErrors] = useState>(new Map()); const hasUserToggledRef = useRef(false); + const panelRef = useRef(null); useEffect(() => { if (defaultExpanded && !hasUserToggledRef.current) { @@ -191,6 +192,13 @@ const PeerList: React.FC = ({ } }, [isExpanded]); + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + if (isExpanded) panel.removeAttribute('inert'); + else panel.setAttribute('inert', ''); + }, [isExpanded]); + const handleCollapseTransitionEnd = useCallback((event: React.TransitionEvent) => { if (event.target !== event.currentTarget) return; if (!isExpanded) { @@ -638,14 +646,11 @@ const PeerList: React.FC = ({ position="right" width="w-64" focusable + ariaLabel="Peer list help" trigger={( - + )} content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

@@ -692,12 +697,12 @@ const PeerList: React.FC = ({ {/* Collapsible Content */}
{shouldRenderContent && ( diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx index 71bfead..543eca3 100644 --- a/frontend/components/ui/tooltip.tsx +++ b/frontend/components/ui/tooltip.tsx @@ -10,6 +10,7 @@ interface TooltipProps { width?: string; triggerClassName?: string; focusable?: boolean; + ariaLabel?: string; } const Tooltip: React.FC = ({ @@ -20,6 +21,7 @@ const Tooltip: React.FC = ({ width = 'w-72', triggerClassName, focusable = false, + ariaLabel, }) => { const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); @@ -123,6 +125,7 @@ const Tooltip: React.FC = ({ type="button" {...commonProps} className={cn('inline-flex align-middle', triggerClassName)} + aria-label={ariaLabel} > {triggerContent} diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index b9ab366..13d1a98 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -50,6 +50,12 @@ describe('getValidRelays', () => { }); }); + it('filters localhost hostname with trailing dot when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://localhost.:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 4f84237..767d268 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -63,7 +63,7 @@ function extractIpv4MappedIpv6(hostname: string): string | null { } function isLoopbackRelayHost(hostname: string): boolean { - let normalized = hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); + let normalized = hostname.replace(/\.+$/, '').replace(/^\[(.*)\]$/, '$1').toLowerCase(); if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; const mappedIpv4 = extractIpv4MappedIpv6(normalized); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 53cf269..1d608cd 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -91,15 +91,11 @@ try { await connectWithTimeout(node, relayUrl); console.log('[cosigner] Connected. Pubkey:', node.pubkey); const filter = node.client?.filter; - const privateFilter = node.client?._filter; if (filter !== undefined) { console.log('[cosigner] Filter (public):', safeStringify(filter)); - } else if (privateFilter !== undefined) { - // TODO: Remove private fallback once @frostr/igloo-core exposes a stable public filter accessor. - console.warn('[cosigner] Filter fallback in use: node.client._filter (private internals)'); - console.log('[cosigner] Filter (private fallback):', safeStringify(privateFilter)); } else { - console.warn('[cosigner] Filter unavailable on node.client (public and private fields missing)'); + // TODO: Track upstream accessor support in @frostr/igloo-core if filter visibility is needed. + console.warn('[cosigner] Filter unavailable on node.client.filter (public accessor missing)'); } } catch (err) { diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index aaf4f26..deaa742 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -1,6 +1,6 @@ /** * UI event-log smoke tests. - * Signing operations performed in 04-sign.spec.ts will have produced log entries. + * Signing operations performed in 04-sign.e2e.ts will have produced log entries. */ import { test, expect, request } from '@playwright/test'; diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 54f0181..1ce7913 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -80,6 +80,10 @@ test.describe('UI – Authenticated app', () => { }); test('Event Log section is visible on Signer tab and shows no errors', async ({ page }) => { + const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); + await expect(signerTab).toBeVisible({ timeout: 8_000 }); + await signerTab.click(); + // The Event Log is a collapsible section embedded in the Signer tab (not a top-level tab). // It renders a div with role="button" and a span containing "Event Log". const eventLogToggle = page.locator('[role="button"]:has-text("Event Log")').first(); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index a27f818..68a70d1 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -32,8 +32,13 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ apiKey: z.string().nullable(), apiKeyId: z.string().nullable(), groupCredential: z.string().min(1, 'groupCredential must be non-empty'), - shareCredentials: z.array(z.string().min(1, 'share credential must be non-empty')), - groupPubkeyHex: z.string(), + shareCredentials: z + .array(z.string().min(1, 'share credential must be non-empty')) + .nonempty('shareCredentials must contain at least one credential'), + groupPubkeyHex: z + .string() + .min(1, 'groupPubkeyHex must be non-empty') + .regex(/^([0-9a-fA-F]+)$/, 'groupPubkeyHex must be a hex string'), adminUsername: z.string().min(1, 'adminUsername must be non-empty'), adminPassword: z.string().min(1, 'adminPassword must be non-empty'), adminSecret: z.string().min(1, 'adminSecret must be non-empty'), diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 7310f76..e6a17e8 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -4,6 +4,12 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +function normalizeOptionalEnv(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function loadFixtureTestKeysetSecret(): string | undefined { const fixturePath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -23,8 +29,8 @@ function loadFixtureTestKeysetSecret(): string | undefined { } const TEST_KEYSET_SECRET = - process.env.TEST_KEYSET_SECRET ?? - process.env.TEST_NSEC_HEX ?? + normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? + normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? loadFixtureTestKeysetSecret(); if (!TEST_KEYSET_SECRET) { throw new Error( From 61bd400d6ada15f63cb077e0eca5685b53a79747 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 19:21:36 -0600 Subject: [PATCH 19/32] test: clarify configure navigation e2e flow --- tests/e2e/specs/08-ui.e2e.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 1ce7913..92ac82b 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -53,19 +53,15 @@ test.describe('UI – Authenticated app', () => { await expect(page.locator('body')).toContainText(/\bnode\s+(active|inactive)\b/i, { timeout: 8_000 }); }); - test('Configure tab is accessible', async ({ page }) => { - const configureTab = page - .locator('[role="tab"]:has-text("Configure"), button:has-text("Configure"), a:has-text("Configure")') - .first(); - await expect(configureTab).toBeVisible({ timeout: 8_000 }); - await configureTab.click(); - // After clicking, the configure panel content should appear + test('Back to Configure button navigates to configuration page', async ({ page }) => { + // In signer view, config form copy should not be visible yet. + await expect(page.locator('body')).not.toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); + + const backToConfigure = page.locator('button:has-text("Back to Configure")').first(); + await expect(backToConfigure).toBeVisible({ timeout: 8_000 }); + await backToConfigure.click(); await page.waitForLoadState('networkidle'); - // Scope to likely configure containers to avoid matching unrelated page inputs. - const configContent = page.locator( - '[role="tabpanel"] input, [role="tabpanel"] textarea, [role="tabpanel"] [data-testid*="cred"], [data-testid*="config"] input, [data-testid*="config"] textarea, [id*="config"] input, [id*="config"] textarea' - ).first(); - await expect(configContent).toBeVisible({ timeout: 8_000 }); + await expect(page.locator('body')).toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); }); test('API Keys tab is accessible', async ({ page }) => { From 3b8c8257886653bb53d9047a7245c3bfd18d4427 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 11:26:29 -0600 Subject: [PATCH 20/32] fix: address review findings across routes, docs, and e2e --- .github/workflows/release.yml | 4 +- docs/openapi/README.md | 6 +- docs/openapi/openapi.json | 48 ++-- docs/openapi/openapi.yaml | 19 +- frontend/components/Configure.tsx | 35 ++- frontend/components/Signer.tsx | 24 +- frontend/components/nip46/RelaySettings.tsx | 8 +- frontend/components/nip46/Requests.tsx | 22 +- frontend/components/ui/card.tsx | 2 +- frontend/components/ui/collapsible.tsx | 3 +- .../components/ui/input-with-validation.tsx | 3 +- frontend/components/ui/peer-list.tsx | 8 +- frontend/components/ui/tooltip.tsx | 41 ++- llm/implementation/e2e-smoke-tests.md | 2 +- playwright-report/index.html | 85 ------ scripts/patch-zod-compat.mjs | 7 +- src/class/relay.test.ts | 14 + src/config/crypto.ts | 4 +- src/db/migrator.ts | 2 +- src/routes/admin.ts | 8 +- src/routes/auth.ts | 20 +- src/routes/env.ts | 9 +- src/routes/index.ts | 244 +++++++++--------- src/routes/nip04.ts | 4 + src/routes/nip44.ts | 12 +- src/routes/nip46.ts | 83 +++--- src/routes/onboarding.ts | 5 +- src/routes/status.ts | 2 +- src/routes/user.ts | 4 +- src/routes/utils.test.ts | 20 ++ src/routes/utils.ts | 7 +- src/server.ts | 8 +- src/utils/rate-limiter.ts | 10 +- tests/e2e/cosigner.mjs | 11 +- tests/e2e/global-setup.ts | 29 ++- tests/e2e/specs/02-status-peers.e2e.ts | 15 +- tests/e2e/specs/04-sign.e2e.ts | 7 +- tests/e2e/specs/06-event-log.e2e.ts | 5 +- tests/routes/admin.whoami.session.spec.ts | 4 +- tests/routes/env.db-mode.spec.ts | 2 +- tests/routes/helpers/script-runner.ts | 5 +- 41 files changed, 472 insertions(+), 379 deletions(-) delete mode 100644 playwright-report/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a42ac50..8163bb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,6 @@ name: Release on: - push: - branches: [ master ] workflow_dispatch: inputs: version: @@ -197,7 +195,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: master + ref: refs/tags/${{ needs.release.outputs.new_version }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/docs/openapi/README.md b/docs/openapi/README.md index d27d77e..b9fecba 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -4,8 +4,8 @@ This directory contains the comprehensive OpenAPI 3.1 specification for the Iglo ## Files -- **`openapi/openapi.yaml`** - Complete OpenAPI 3.1 specification in YAML format -- **`openapi/openapi.json`** - Bundled JSON representation generated from the YAML spec +- **`docs/openapi/openapi.yaml`** - Complete OpenAPI 3.1 specification in YAML format +- **`docs/openapi/openapi.json`** - Bundled JSON representation generated from the YAML spec - **`README.md`** - This documentation file ## Accessing the Documentation @@ -102,7 +102,7 @@ This ensures the YAML syntax is correct and the specification is well-formed. When adding or modifying API endpoints: -1. Update the corresponding section in `openapi/openapi.yaml` +1. Update the corresponding section in `docs/openapi/openapi.yaml` 2. Add/update request and response schemas 3. Include relevant examples 4. Validate the specification: `bun run docs:validate` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 0bbca29..2ac19fa 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -419,22 +419,14 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest", + "description": "Bad request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthStatus" + "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "enabled": true, - "methods": [ - "api-key", - "bearer", - "basic-auth", - "session" - ], - "rateLimiting": true, - "sessionTimeout": 3600 + "error": "Invalid authentication status request" } } } @@ -3008,14 +3000,13 @@ "methods": { "type": "array", "items": { - "type": "string", - "enum": [ - "api-key", - "bearer", - "basic-auth", - "session" - ] - }, + "type": "string", + "enum": [ + "api-key", + "basic-auth", + "session" + ] + }, "description": "Available authentication methods" }, "rateLimiting": { @@ -3526,7 +3517,7 @@ "type": "object", "description": "Client-supplied fields when creating or updating a NIP‑46 session", "properties": { - "client_pubkey": { + "pubkey": { "type": "string", "description": "Client public key (hex encoded)" }, @@ -3554,7 +3545,7 @@ } }, "required": [ - "client_pubkey" + "pubkey" ] }, "Nip46Session": { @@ -4073,13 +4064,12 @@ ] }, "example": { - "error": "Authentication required", - "authMethods": [ - "api-key", - "bearer", - "basic-auth", - "session" - ] + "error": "Authentication required", + "authMethods": [ + "api-key", + "basic-auth", + "session" + ] } } } @@ -4189,4 +4179,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index db5e2ca..b654aad 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -327,16 +327,13 @@ paths: schema: $ref: '#/components/schemas/AuthStatus' '400': - $ref: '#/components/responses/BadRequest' + description: Bad request content: application/json: schema: - $ref: '#/components/schemas/AuthStatus' + $ref: '#/components/schemas/ErrorResponse' example: - enabled: true - methods: ["api-key", "bearer", "basic-auth", "session"] - rateLimiting: true - sessionTimeout: 3600 + error: "Invalid authentication status request" /api/auth/login: post: @@ -1960,7 +1957,7 @@ components: type: array items: type: string - enum: ["api-key", "bearer", "basic-auth", "session"] + enum: ["api-key", "basic-auth", "session"] description: Available authentication methods rateLimiting: type: boolean @@ -2323,7 +2320,7 @@ components: type: object description: Client-supplied fields when creating or updating a NIP‑46 session properties: - client_pubkey: + pubkey: type: string description: Client public key (hex encoded) status: @@ -2338,7 +2335,7 @@ components: description: Preferred relays for the session policy: $ref: '#/components/schemas/Nip46Policy' - required: [client_pubkey] + required: [pubkey] Nip46Session: type: object @@ -2631,7 +2628,7 @@ components: type: string example: error: "Authentication required" - authMethods: ["api-key", "bearer", "basic-auth", "session"] + authMethods: ["api-key", "basic-auth", "session"] InternalServerError: description: Internal server error @@ -2720,4 +2717,4 @@ tags: - name: Onboarding description: First-run onboarding and admin validation (database mode) - name: Event Log - description: Persisted UI event log endpoints (database mode only) \ No newline at end of file + description: Persisted UI event log endpoints (database mode only) diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index 7ed4967..c7f9f32 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -556,10 +556,28 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav setIsGenerating(true); try { + const resolveRelaysToSave = (): string[] => { + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays; + } + if (typeof advancedSettings.RELAYS === 'string' && advancedSettings.RELAYS.trim().length > 0) { + try { + const parsed = JSON.parse(advancedSettings.RELAYS); + if (Array.isArray(parsed) && parsed.every(relay => typeof relay === 'string') && parsed.length > 0) { + return parsed; + } + } catch { + // fall back to default relay list below + } + } + return ["wss://relay.primal.net"]; + }; + // Save credentials based on mode if (isHeadlessMode) { // Headless mode - save to env - await fetch('/api/env', { + const relaysToSave = resolveRelaysToSave(); + const response = await fetch('/api/env', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -569,16 +587,19 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav SHARE_CRED: share, GROUP_CRED: groupCredential, GROUP_NAME: keysetName, - // Ensure we have at least one valid relay for the server to use - RELAYS: JSON.stringify(["wss://relay.primal.net"]) + RELAYS: JSON.stringify(relaysToSave) }) }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(detail || `Failed to save headless credentials (${response.status})`); + } } else { // Database mode - save to user credentials // Preserve existing relays or use default if none exist - const relaysToSave = existingRelays || ["wss://relay.primal.net"]; + const relaysToSave = resolveRelaysToSave(); - await fetch('/api/user/credentials', { + const response = await fetch('/api/user/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -591,6 +612,10 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav relays: relaysToSave }) }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(detail || `Failed to save credentials (${response.status})`); + } } setHasExistingCredentials(true); diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index e219b56..0b2eea7 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -382,27 +382,19 @@ const Signer = forwardRef(({ initialData, authHeaders const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl = `${protocol}//${window.location.host}/api/events`; - // Add authentication parameters for WebSocket connection - // Since WebSocket doesn't support custom headers during upgrade, - // we need to pass auth info via URL parameters - const params = new URLSearchParams(); - - // Check if we have auth headers and convert them to URL params + // Avoid exposing long-lived credentials in URL query params. + // Prefer WebSocket subprotocol auth hints supported by the backend. + const protocols: string[] = []; const currentAuth = authHeadersRef.current; if (currentAuth['X-API-Key']) { - params.set('apiKey', currentAuth['X-API-Key']); + protocols.push(`api-key.${currentAuth['X-API-Key']}`); } else if (currentAuth['X-Session-ID']) { - params.set('sessionId', currentAuth['X-Session-ID']); + protocols.push(`session.${currentAuth['X-Session-ID']}`); } else if (currentAuth['Authorization'] && currentAuth['Authorization'].startsWith('Basic ')) { - // For basic auth, we'll rely on cookies or handle it server-side - // The server should accept the connection if the user is already authenticated - } - - if (params.toString()) { - wsUrl += '?' + params.toString(); + // For basic auth, rely on existing browser credentials/cookies. } - - ws = new WebSocket(wsUrl); + + ws = protocols.length > 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl); ws.onopen = () => { isConnecting = false; diff --git a/frontend/components/nip46/RelaySettings.tsx b/frontend/components/nip46/RelaySettings.tsx index a3708f0..e4b8206 100644 --- a/frontend/components/nip46/RelaySettings.tsx +++ b/frontend/components/nip46/RelaySettings.tsx @@ -75,7 +75,13 @@ export function RelaySettings({ relays, onAdd, onRemove, loading = false, saving size="sm" icon={} tooltip="Remove relay" - onClick={() => onRemove(relay)} + onClick={async () => { + try { + await onRemove(relay) + } catch (error) { + console.error('[RelaySettings] Failed to remove relay:', error) + } + }} disabled={saving} /> diff --git a/frontend/components/nip46/Requests.tsx b/frontend/components/nip46/Requests.tsx index 17d752c..0cfe2c8 100644 --- a/frontend/components/nip46/Requests.tsx +++ b/frontend/components/nip46/Requests.tsx @@ -29,6 +29,7 @@ interface ParsedRequest { eventKind: number | null eventTemplate: Record | null contentPreview: string | null + contentTruncated: boolean } const DEFAULT_POLICY: PermissionPolicy = { methods: {}, kinds: {} } @@ -43,6 +44,14 @@ const formatTimestamp = (value: string) => { return Number.isNaN(date.getTime()) ? 'N/A' : date.toLocaleString() } +const sanitizePreview = (value: string): string => { + return value + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') + .replace(/[\u202A-\u202E\u2066-\u2069]/g, '') + .replace(/\s+/g, ' ') + .trim() +} + const parseRequest = (record: Nip46RequestApi): ParsedRequest => { let method = record.method let params: any[] = [] @@ -82,9 +91,11 @@ const parseRequest = (record: Nip46RequestApi): ParsedRequest => { } } - const contentPreview = eventTemplate && typeof eventTemplate.content === 'string' - ? eventTemplate.content.trim().slice(0, 160) + const sanitizedContent = eventTemplate && typeof eventTemplate.content === 'string' + ? sanitizePreview(eventTemplate.content) : null + const contentPreview = sanitizedContent ? sanitizedContent.slice(0, 160) : null + const contentTruncated = !!sanitizedContent && sanitizedContent.length > 160 return { record, @@ -96,7 +107,8 @@ const parseRequest = (record: Nip46RequestApi): ParsedRequest => { sessionUrl, eventKind, eventTemplate, - contentPreview + contentPreview, + contentTruncated } } @@ -197,7 +209,7 @@ export function Requests({
{parsedRequests.map(entry => { - const { record, method, sessionName, sessionImage, sessionUrl, eventKind, eventTemplate, params, contentPreview } = entry + const { record, method, sessionName, sessionImage, sessionUrl, eventKind, eventTemplate, params, contentPreview, contentTruncated } = entry const policy = policies[record.session_pubkey] ?? DEFAULT_POLICY const methodAllowed = policy.methods?.[method] === true const wildcardKind = policy.kinds?.['*'] === true @@ -276,7 +288,7 @@ export function Requests({
{contentPreview ? ( -
{contentPreview}{eventTemplate?.content && eventTemplate.content.length > 160 ? '…' : ''}
+
{contentPreview}{contentTruncated ? '…' : ''}
) : null}
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index 7881a5c..5588b38 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -29,7 +29,7 @@ const CardHeader = React.forwardRef< CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< - HTMLParagraphElement, + HTMLHeadingElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

= ({ )} onClick={toggleExpanded} role="button" + aria-expanded={isExpanded} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -72,4 +73,4 @@ const Collapsible: React.FC = ({ ); }; -export { Collapsible }; \ No newline at end of file +export { Collapsible }; diff --git a/frontend/components/ui/input-with-validation.tsx b/frontend/components/ui/input-with-validation.tsx index 6866ca2..8401b27 100644 --- a/frontend/components/ui/input-with-validation.tsx +++ b/frontend/components/ui/input-with-validation.tsx @@ -37,6 +37,7 @@ const InputWithValidation: React.FC = ({ id={inputId} value={value} onChange={(e) => onChange(e.target.value)} + required={isRequired} className={cn( "bg-gray-800/50 border-gray-700/50 text-blue-300 py-2 text-sm w-full", hasError && "border-red-500", @@ -51,4 +52,4 @@ const InputWithValidation: React.FC = ({ ); }; -export { InputWithValidation }; \ No newline at end of file +export { InputWithValidation }; diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 7a1471f..1d8f717 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef, useId } from 'react'; import { Button } from './button'; import { IconButton } from './icon-button'; import { Badge, type BadgeProps } from './badge'; @@ -179,6 +179,7 @@ const PeerList: React.FC = ({ const [policyPeerErrors, setPolicyPeerErrors] = useState>(new Map()); const hasUserToggledRef = useRef(false); const panelRef = useRef(null); + const panelId = useId(); useEffect(() => { if (defaultExpanded && !hasUserToggledRef.current) { @@ -627,6 +628,7 @@ const PeerList: React.FC = ({ onClick={handleToggle} role="button" aria-expanded={isExpanded} + aria-controls={panelId} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -698,6 +700,9 @@ const PeerList: React.FC = ({ {/* Collapsible Content */}
= ({ width="w-72" triggerClassName="cursor-help" focusable + ariaLabel="Policy controls help" trigger={} content={
diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx index 543eca3..58d9059 100644 --- a/frontend/components/ui/tooltip.tsx +++ b/frontend/components/ui/tooltip.tsx @@ -2,33 +2,48 @@ import React, { useState, ReactNode, useRef, useEffect, useCallback, useId } fro import { createPortal } from 'react-dom'; import { cn } from "../../lib/utils"; -interface TooltipProps { +interface TooltipSharedProps { trigger: ReactNode; content: ReactNode; className?: string; position?: 'top' | 'right' | 'bottom' | 'left'; width?: string; triggerClassName?: string; - focusable?: boolean; - ariaLabel?: string; } -const Tooltip: React.FC = ({ - trigger, - content, - className, - position = 'left', - width = 'w-72', - triggerClassName, - focusable = false, - ariaLabel, -}) => { +type TooltipProps = + | (TooltipSharedProps & { + focusable: true; + ariaLabel: string; + }) + | (TooltipSharedProps & { + focusable?: false; + ariaLabel?: string; + }); + +const Tooltip: React.FC = (props) => { + const { + trigger, + content, + className, + position = 'left', + width = 'w-72', + triggerClassName, + } = props; + const focusable = props.focusable ?? false; + const ariaLabel = props.ariaLabel; const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const tooltipId = useId(); const triggerRef = useRef(null); const tooltipRef = useRef(null); + useEffect(() => { + if (focusable && !ariaLabel) { + console.error('[Tooltip] focusable tooltips require ariaLabel for accessibility.'); + } + }, [focusable, ariaLabel]); + const updatePosition = useCallback(() => { if (typeof window === 'undefined' || !triggerRef.current || !tooltipRef.current) { return; diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index 0bb18d7..19fcdc4 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -1,7 +1,7 @@ # E2E Smoke Test Suite (Playwright – DB Mode) Last verified: 2026-02-20 -Test count: 62 (54 API + 8 UI) — all passing +Status snapshot: all Playwright smoke tests passing as of the verification date above. For current counts, run `npx playwright test --list`. ## Purpose diff --git a/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index 41e3ff2..0000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/scripts/patch-zod-compat.mjs b/scripts/patch-zod-compat.mjs index 5006721..00736a8 100644 --- a/scripts/patch-zod-compat.mjs +++ b/scripts/patch-zod-compat.mjs @@ -9,7 +9,12 @@ const ZOD_DIR = join(PROJECT_ROOT, 'node_modules', 'zod') const NOSTR_SCHEMA_DIR = join(PROJECT_ROOT, 'node_modules', '@cmdcode', 'nostr-connect', 'dist', 'schema') function ensureFile(path, content) { - const current = readFileSync(path, 'utf8') + let current = '' + try { + current = readFileSync(path, 'utf8') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } if (current !== content) { writeFileSync(path, content) } diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index 2c9db2a..58877f2 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -68,6 +68,20 @@ describe('NostrRelay REQ handling', () => { expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); }); + it('rejects REQ with no filters', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-empty-no-filters'])); + + expect(relay.subs.size).toBe(0); + const messages = decodeSent(socket); + expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); + }); + it('accepts canonical multi-filter REQ payloads and creates a subscription', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); diff --git a/src/config/crypto.ts b/src/config/crypto.ts index d72e59b..56d6f36 100644 --- a/src/config/crypto.ts +++ b/src/config/crypto.ts @@ -5,7 +5,7 @@ // PBKDF2 Configuration for Key Derivation export const PBKDF2_CONFIG = { - ITERATIONS: 200000, // Number of iterations (higher = more secure but slower) + ITERATIONS: 600000, // OWASP-aligned baseline for PBKDF2-HMAC-SHA256 KEY_LENGTH: 32, // 256 bits ALGORITHM: 'sha256', // Hash algorithm } as const; @@ -60,4 +60,4 @@ export function isPasswordValid(pwd: string): boolean { return false; } return VALIDATION.PASSWORD_REGEX.test(pwd); -} \ No newline at end of file +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index b0d2f9e..987516c 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -28,7 +28,7 @@ export function runMigrations(migrationsDirRel = 'src/db/migrations', opts?: { s // Security: Ensure migrations directory is within project boundaries const projectRoot = path.resolve(process.cwd()) - if (!dir.startsWith(projectRoot + path.sep) && dir !== projectRoot) { + if (!dir.startsWith(projectRoot + path.sep)) { throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 2a8e4e0..5f39256 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -126,13 +126,17 @@ export async function handleAdminRoute( // Check rate limit before admin authentication to prevent brute force attacks const rate = await checkRateLimit(req, 'auth', { clientIp: _context.clientIp }); if (!rate.allowed) { + const fallbackRetryAfterSeconds = Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString(); + const retryAfterSeconds = typeof rate.resetAt === 'number' + ? Math.max(1, Math.ceil((rate.resetAt - Date.now()) / 1000)).toString() + : fallbackRetryAfterSeconds; return Response.json( { error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, - 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() + 'Retry-After': retryAfterSeconds } } ); @@ -272,7 +276,7 @@ export async function handleAdminRoute( createdAt: key.createdAt, updatedAt: key.updatedAt, lastUsedAt: key.lastUsedAt, - lastUsedIp: key.lastUsedIp, + lastUsedIp: null, revokedAt: key.revokedAt, revokedReason: key.revokedReason, createdByUserId: key.createdByUserId, diff --git a/src/routes/auth.ts b/src/routes/auth.ts index d496161..8e910a9 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -453,7 +453,7 @@ export async function checkRateLimit( req: Request, bucket: string = 'auth', opts?: { windowMs?: number; max?: number; clientIp?: string } -): Promise<{ allowed: boolean; remaining: number }> { +): Promise<{ allowed: boolean; remaining: number; resetAt?: number }> { if (!AUTH_CONFIG.RATE_LIMIT_ENABLED) { return { allowed: true, remaining: AUTH_CONFIG.RATE_LIMIT_MAX }; } @@ -469,7 +469,8 @@ export async function checkRateLimit( return { allowed: result.allowed, - remaining: result.remaining + remaining: result.remaining, + resetAt: result.resetAt }; } @@ -1009,7 +1010,7 @@ export async function handleLogin(req: Request): Promise { return Response.json({ success: false, error: 'Database temporarily unavailable. Please try again.' - }, { status: 503 }); // 503 Service Unavailable + }, { status: 503, headers: baseHeaders }); // 503 Service Unavailable } // For unexpected errors, log but don't expose details @@ -1099,6 +1100,19 @@ export function handleLogout(req: Request): Response { if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers }); } + + if (req.method !== 'POST') { + return Response.json( + { error: 'Method not allowed' }, + { + status: 405, + headers: { + ...headers, + 'Allow': 'POST, OPTIONS' + } + } + ); + } const sessionId = req.headers.get('x-session-id') || extractSessionFromCookie(req); diff --git a/src/routes/env.ts b/src/routes/env.ts index 20667f4..861f17c 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -94,8 +94,13 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // Resolve authenticated DB user id (database mode only) const authenticatedNumericUserId = (() => { if (HEADLESS || !auth?.authenticated) return null; - if (typeof auth.userId === 'number') return BigInt(auth.userId); - if (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) return BigInt(auth.userId); + if (typeof auth.userId === 'number') { + if (!Number.isInteger(auth.userId) || auth.userId <= 0) return null; + return BigInt(auth.userId); + } + if (typeof auth.userId === 'string' && /^[1-9]\d*$/.test(auth.userId)) { + return BigInt(auth.userId); + } return null; })(); const isRoleAdmin = await (async () => { diff --git a/src/routes/index.ts b/src/routes/index.ts index 8183c72..5dac5cd 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -190,147 +190,143 @@ export async function handleRequest( // Admin endpoints have their own ADMIN_SECRET authentication const isAdminEndpoint = url.pathname.startsWith('/api/admin'); - // Authentication check for API endpoints (skip public endpoints, status, and admin) - if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && !isAdminEndpoint) { - const authResult = await authenticate(req); - - if (authResult.rateLimited) { - const response = Response.json({ - error: 'Rate limit exceeded. Try again later.' - }, { - status: 429, - headers: { - ...headers, - 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() - } - }); - finalizeAuth(); - return response; - } - - if (!authResult.authenticated) { - // Don't set WWW-Authenticate header to avoid browser's native auth dialog - // The frontend will handle authentication through its own UI - const response = Response.json({ - error: authResult.error || 'Authentication required', - authMethods: getAuthStatus() - }, { - status: 401, - headers - }); - finalizeAuth(); - return response; - } - - authInfo = createRequestAuth({ - userId: authResult.userId, - authenticated: true, - derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, - sessionId: authResult.sessionId, - hasPassword: authResult.hasPassword - }); - } else if (isStatusEndpoint && AUTH_CONFIG.ENABLED) { - // Special handling for /api/status: attempt authentication if headers are present - // but don't require it (allow unauthenticated health checks) - try { + try { + // Authentication check for API endpoints (skip public endpoints, status, and admin) + if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && !isAdminEndpoint) { const authResult = await authenticate(req); - // Only use auth info if authentication actually succeeded (not rate limited or failed) - if (authResult.authenticated && !authResult.rateLimited) { - // Create auth info with secure ephemeral storage for secrets - authInfo = createRequestAuth({ - userId: authResult.userId, - authenticated: true, - derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, - sessionId: authResult.sessionId, - hasPassword: authResult.hasPassword + if (authResult.rateLimited) { + return Response.json({ + error: 'Rate limit exceeded. Try again later.' + }, { + status: 429, + headers: { + ...headers, + 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() + } }); } - // If authentication failed or was rate limited, authInfo remains null (unauthenticated access) - } catch (error) { - // If authentication throws an error, allow unauthenticated access - // Authentication attempt failed, allowing unauthenticated access for health checks - } - } - - // Note: Authentication is now handled above for all non-public API endpoints - - // Handle user routes (database mode only) - if (!HEADLESS && url.pathname.startsWith('/api/user')) { - const userResult = await handleUserRoute(req, url, privilegedContext, authInfo); - if (userResult) { - finalizeAuth(); - return userResult; - } - } - - // Handle admin routes (database mode only). Admin routes primarily use ADMIN_SECRET, - // but when a valid session exists for an admin user we allow that too. - if (!HEADLESS && url.pathname.startsWith('/api/admin')) { - // Attempt optional authentication for admin endpoints to support session-admin access. - // Do not enforce auth result here; handleAdminRoute will decide based on ADMIN_SECRET or session. - if (AUTH_CONFIG.ENABLED && !authInfo) { + + if (!authResult.authenticated) { + // Don't set WWW-Authenticate header to avoid browser's native auth dialog + // The frontend will handle authentication through its own UI + return Response.json({ + error: authResult.error || 'Authentication required', + authMethods: getAuthStatus() + }, { + status: 401, + headers + }); + } + + authInfo = createRequestAuth({ + userId: authResult.userId, + authenticated: true, + derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, + sessionId: authResult.sessionId, + hasPassword: authResult.hasPassword + }); + } else if (isStatusEndpoint && AUTH_CONFIG.ENABLED) { + // Special handling for /api/status: attempt authentication if headers are present + // but don't require it (allow unauthenticated health checks) try { - const adminAuth = await authenticate(req); - if (adminAuth.authenticated && !adminAuth.rateLimited) { + const authResult = await authenticate(req); + + // Only use auth info if authentication actually succeeded (not rate limited or failed) + if (authResult.authenticated && !authResult.rateLimited) { + // Create auth info with secure ephemeral storage for secrets authInfo = createRequestAuth({ - userId: adminAuth.userId, + userId: authResult.userId, authenticated: true, - derivedKey: adminAuth.derivedKey ? adminAuth.derivedKey : undefined, - sessionId: adminAuth.sessionId, - hasPassword: adminAuth.hasPassword + derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, + sessionId: authResult.sessionId, + hasPassword: authResult.hasPassword }); } - } catch {} + // If authentication failed or was rate limited, authInfo remains null (unauthenticated access) + } catch (error) { + // If authentication throws an error, allow unauthenticated access + // Authentication attempt failed, allowing unauthenticated access for health checks + } } - const adminResult = await handleAdminRoute(req, url, baseContext, authInfo); - if (adminResult) { - finalizeAuth(); - return adminResult; + // Note: Authentication is now handled above for all non-public API endpoints + + // Handle user routes (database mode only) + if (!HEADLESS && url.pathname.startsWith('/api/user')) { + const userResult = await handleUserRoute(req, url, privilegedContext, authInfo); + if (userResult) { + return userResult; + } } - } - - // Handle privileged routes separately - if (needsPrivilegedAccess && url.pathname.startsWith('/api/env')) { - const result = await handleEnvRoute(req, url, privilegedContext, authInfo); - if (result) { - finalizeAuth(); - return result; + + // Handle admin routes (database mode only). Admin routes primarily use ADMIN_SECRET, + // but when a valid session exists for an admin user we allow that too. + if (!HEADLESS && url.pathname.startsWith('/api/admin')) { + // Attempt optional authentication for admin endpoints to support session-admin access. + // Do not enforce auth result here; handleAdminRoute will decide based on ADMIN_SECRET or session. + if (AUTH_CONFIG.ENABLED && !authInfo) { + try { + const adminAuth = await authenticate(req); + if (adminAuth.authenticated && !adminAuth.rateLimited) { + authInfo = createRequestAuth({ + userId: adminAuth.userId, + authenticated: true, + derivedKey: adminAuth.derivedKey ? adminAuth.derivedKey : undefined, + sessionId: adminAuth.sessionId, + hasPassword: adminAuth.hasPassword + }); + } + } catch {} + } + + const adminResult = await handleAdminRoute(req, url, baseContext, authInfo); + if (adminResult) { + return adminResult; + } + } + + // Handle privileged routes separately + if (needsPrivilegedAccess && url.pathname.startsWith('/api/env')) { + const result = await handleEnvRoute(req, url, privilegedContext, authInfo); + if (result) { + return result; + } } - } - if (!HEADLESS && url.pathname.startsWith('/api/nip46/')) { - const nip46Result = await handleNip46Route(req, url, privilegedContext, authInfo); - if (nip46Result) { - finalizeAuth(); - return nip46Result; + if (!HEADLESS && url.pathname.startsWith('/api/nip46/')) { + const nip46Result = await handleNip46Route(req, url, privilegedContext, authInfo); + if (nip46Result) { + return nip46Result; + } } - } - // Try each non-privileged route handler in order - // Note: These handlers now accept auth as an optional parameter - const routeHandlers = [ - handleStatusRoute, // Allow unauthenticated for health checks - handleUpdateRoute, - handleEventLogRoute, - handlePeersRoute, - handleSignRoute, - handleNip44Route, - handleNip04Route, - handleRecoveryRoute, - ]; + // Try each non-privileged route handler in order + // Note: These handlers now accept auth as an optional parameter + const routeHandlers = [ + handleStatusRoute, // Allow unauthenticated for health checks + handleUpdateRoute, + handleEventLogRoute, + handlePeersRoute, + handleSignRoute, + handleNip44Route, + handleNip04Route, + handleRecoveryRoute, + ]; - for (const handler of routeHandlers) { - const result = await handler(req, url, context, authInfo); - if (result) { - finalizeAuth(); - return result; + for (const handler of routeHandlers) { + const result = await handler(req, url, context, authInfo); + if (result) { + return result; + } } - } - // If no route matched, return 404 - const notFound = new Response('Not Found', { status: 404 }); - finalizeAuth(); - return notFound; + // If no route matched, return 404 + if (url.pathname.startsWith('/api/')) { + return Response.json({ error: 'Not Found' }, { status: 404, headers }); + } + return new Response('Not Found', { status: 404 }); + } finally { + finalizeAuth(); + } } diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index 9592a0e..135543c 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -79,6 +79,10 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon if (!isContentLengthWithin(req, DEFAULT_MAX_JSON_BODY)) { return Response.json({ error: 'Request too large' }, { status: 413, headers }) } + const authContext = _auth ?? context.auth + if (!authContext?.authenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401, headers }) + } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }) // Separate bucket for e2e crypto ops diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index 3947620..b0d71ef 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -65,17 +65,15 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon const mode = url.pathname.endsWith('/encrypt') ? 'encrypt' : url.pathname.endsWith('/decrypt') ? 'decrypt' : null; if (!mode) return Response.json({ error: 'Unknown operation' }, { status: 404, headers }); - // Platform-agnostic hex to Uint8Array conversion - const hexBytes = secretHex.match(/.{1,2}/g); - if (!hexBytes) { - throw new Error('Invalid hex string format'); + const key = Uint8Array.from(Buffer.from(secretHex, 'hex')); + if (key.length !== 32) { + throw new Error('Invalid shared secret length'); } - const key = new Uint8Array(hexBytes.map(byte => parseInt(byte, 16))); if (mode === 'encrypt') { - const ciphertext = await nip44.encrypt(content, key); + const ciphertext = nip44.encrypt(content, key); return Response.json({ result: ciphertext }, { status: 200, headers }); } else { - const plaintext = await nip44.decrypt(content, key); + const plaintext = nip44.decrypt(content, key); return Response.json({ result: plaintext }, { status: 200, headers }); } } catch (e: any) { diff --git a/src/routes/nip46.ts b/src/routes/nip46.ts index 75f1d6a..88c7a5d 100644 --- a/src/routes/nip46.ts +++ b/src/routes/nip46.ts @@ -192,10 +192,6 @@ export async function handleNip46Route( return Response.json({ error: 'NIP-46 persistence unavailable in headless mode' }, { status: 404 }) } - // Ensure database is initialized before processing any NIP46 requests - // This prevents race conditions where routes are accessed before migrations complete - await initializeNip46DB() - const corsHeaders = getSecureCorsHeaders(req) const mergedVary = mergeVaryHeaders(corsHeaders) const headers = { @@ -206,6 +202,16 @@ export async function handleNip46Route( 'Vary': mergedVary, } + // Ensure database is initialized before processing any NIP46 requests. + // This prevents race conditions where routes are accessed before migrations complete. + try { + await initializeNip46DB() + } catch (error) { + console.error('[NIP46] Failed to initialize DB:', error) + const message = error instanceof Error ? error.message : 'Failed to initialize NIP-46 database' + return Response.json({ error: 'DB_INIT_FAILED', message }, { status: 500, headers }) + } + if (req.method === 'OPTIONS') return new Response(null, { status: 200, headers }) // Require authenticated DB user @@ -336,33 +342,33 @@ export async function handleNip46Route( const result = typeof body?.result === 'string' ? body.result : null const errorMessage = typeof body?.error === 'string' ? body.error : null - const policyPatch = parsePolicyPatch(body?.policy) - let existingRecord = policyPatch ? getNip46RequestById(id) : null - if (policyPatch) { - if (!existingRecord) { - return Response.json({ error: 'Request not found' }, { status: 404, headers }) - } - const recordUserId = typeof existingRecord.user_id === 'bigint' - ? existingRecord.user_id.toString() - : String(existingRecord.user_id) - const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) - if (recordUserId !== requestUserId) { - return Response.json({ error: 'Request not found' }, { status: 404, headers }) - } - - const session = getSession(userId, existingRecord.session_pubkey) - if (!session) { - return Response.json({ error: 'Session not found for policy update' }, { status: 404, headers }) - } - - try { - const mergedPolicy = applyPolicyPatch(session.policy, policyPatch) - updatePolicy(userId, existingRecord.session_pubkey, mergedPolicy) - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update policy' - return Response.json({ error: message }, { status: 400, headers }) - } - } + const existingRecord = getNip46RequestById(id) + if (!existingRecord) { + return Response.json({ error: 'Request not found' }, { status: 404, headers }) + } + const recordUserId = typeof existingRecord.user_id === 'bigint' + ? existingRecord.user_id.toString() + : String(existingRecord.user_id) + const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) + if (recordUserId !== requestUserId) { + return Response.json({ error: 'Forbidden' }, { status: 403, headers }) + } + + const policyPatch = parsePolicyPatch(body?.policy) + if (policyPatch) { + const session = getSession(userId, existingRecord.session_pubkey) + if (!session) { + return Response.json({ error: 'Session not found for policy update' }, { status: 404, headers }) + } + + try { + const mergedPolicy = applyPolicyPatch(session.policy, policyPatch) + updatePolicy(userId, existingRecord.session_pubkey, mergedPolicy) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update policy' + return Response.json({ error: message }, { status: 400, headers }) + } + } const record = updateNip46RequestStatus(id, status, { result, error: errorMessage }) if (!record) { @@ -388,6 +394,18 @@ export async function handleNip46Route( return Response.json({ error: 'Field "id" is required' }, { status: 400, headers }) } + const existingRecord = getNip46RequestById(id) + if (!existingRecord) { + return Response.json({ error: 'Request not found' }, { status: 404, headers }) + } + const recordUserId = typeof existingRecord.user_id === 'bigint' + ? existingRecord.user_id.toString() + : String(existingRecord.user_id) + const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) + if (recordUserId !== requestUserId) { + return Response.json({ error: 'Forbidden' }, { status: 403, headers }) + } + deleteNip46Request(id) return Response.json({ ok: true }, { headers }) } @@ -603,6 +621,9 @@ export async function handleNip46Route( if (url.pathname.startsWith('/api/nip46/sessions/') && req.method === 'DELETE') { const pubkey = parsePubkeyFromPath(url.pathname) if (!pubkey || !isValidHex(pubkey)) return Response.json({ error: 'Invalid pubkey' }, { status: 400, headers }) + if (url.pathname !== `/api/nip46/sessions/${pubkey}`) { + return Response.json({ error: 'Not Found' }, { status: 404, headers }) + } const ok = deleteSession(userId, pubkey.toLowerCase()) return Response.json({ ok }, { headers }) } diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index a3d6efa..a25d975 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -267,8 +267,9 @@ const UNIFORM_AUTH_ERROR = { error: 'Authentication failed' }; // - Lowercase letter // - Digit // - Special character (at least one of @$!%*?&, but allows any special chars) -// Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH -const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])\S*$/; +// Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH. +// Whitespace is allowed and preserved by policy. +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).*$/; /** * Validates the admin secret in a timing-safe manner diff --git a/src/routes/status.ts b/src/routes/status.ts index e70593b..2ca85b5 100644 --- a/src/routes/status.ts +++ b/src/routes/status.ts @@ -59,7 +59,7 @@ export async function handleStatusRoute(req: Request, url: URL, context: RouteCo // Lazy-load DB only in non-headless, authenticated path const { userHasStoredCredentials } = await import('../db/database.js'); // Convert to bigint for database operation - const dbUserId = typeof parsedUserId === 'string' ? BigInt(parsedUserId) : parsedUserId; + const dbUserId = typeof parsedUserId === 'string' ? BigInt(parsedUserId) : BigInt(parsedUserId); hasStoredCredentials = userHasStoredCredentials(dbUserId); } } diff --git a/src/routes/user.ts b/src/routes/user.ts index 05ab10c..aa27cc9 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -285,11 +285,11 @@ export async function handleUserRoute( // Validate relays format if (body.relays === null || (Array.isArray(body.relays) && - body.relays.every((r: any) => typeof r === 'string'))) { + body.relays.every((r: any) => typeof r === 'string' && isValidWebSocketUrl(r)))) { updates.relays = body.relays; } else { return Response.json( - { error: 'Invalid relays format. Must be an array of strings or null.' }, + { error: 'Invalid relay URLs. Must use ws:// or wss://' }, { status: 400, headers } ); } diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 13d1a98..cf0c64a 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -56,6 +56,12 @@ describe('getValidRelays', () => { }); }); + it('filters localhost subdomain relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://relay.localhost:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); @@ -100,6 +106,8 @@ describe('normalizeRelayListForEcho', () => { expect( normalizeRelayListForEcho([ 'ws://127.0.0.1:18002', + 'ws://[::1]:18002', + 'ws://[::ffff:127.0.0.1]:18002', 'ws://localhost:18002', 'wss://relay.example.com' ]) @@ -118,4 +126,16 @@ describe('normalizeRelayListForEcho', () => { expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); }); }); + + it('keeps IPv6 localhost relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://[::1]:18002'])).toEqual(['ws://[::1]:18002']); + }); + }); + + it('keeps IPv4-mapped IPv6 relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://[::ffff:127.0.0.1]:18002'])).toEqual(['ws://[::ffff:127.0.0.1]:18002']); + }); + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 767d268..9798f9b 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -64,7 +64,12 @@ function extractIpv4MappedIpv6(hostname: string): string | null { function isLoopbackRelayHost(hostname: string): boolean { let normalized = hostname.replace(/\.+$/, '').replace(/^\[(.*)\]$/, '$1').toLowerCase(); - if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; + if ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' + ) return true; const mappedIpv4 = extractIpv4MappedIpv6(normalized); if (mappedIpv4) { diff --git a/src/server.ts b/src/server.ts index d720965..5fdf3db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -415,12 +415,14 @@ async function initializeDatabase(): Promise { } } -// Initialize database with single exit point -initializeDatabase().catch((err) => { +// Initialize database before starting relay/node setup +try { + await initializeDatabase(); +} catch (err) { console.error('❌ Fatal initialization error:'); console.error(' ', err instanceof Error ? err.message : String(err)); process.exit(1); -}); +} // Create the Nostr relay const relay = new NostrRelay(); diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 8334059..8670b23 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -182,7 +182,8 @@ export class PersistentRateLimiter { } } - throw new RateLimiterUnavailableError(); + // Defensive fallback for type completeness; loop paths above should always return or throw. + return this.checkMemoryLimit(identifier, config, Date.now()); } /** @@ -264,14 +265,13 @@ export class PersistentRateLimiter { if (this.db) { try { - this.db + const result = this.db .prepare('DELETE FROM rate_limits WHERE last_attempt < ?') .run(cutoff); // Only log if entries were deleted - const changes = this.db.query('SELECT changes() as c').get() as { c: number } | null; - if (changes && changes.c > 0) { - console.log(`[RateLimiter] Cleaned up ${changes.c} expired entries`); + if (result.changes > 0) { + console.log(`[RateLimiter] Cleaned up ${result.changes} expired entries`); } } catch (error) { console.error('[RateLimiter] Cleanup failed:', error); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 1d608cd..9e655d7 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -63,6 +63,7 @@ function safeStringify(obj) { } let node; +let shuttingDown = false; try { node = createBifrostNode({ group: groupCred, @@ -74,7 +75,14 @@ try { console.log('[cosigner] Node ready. PubKey:', node.pubkey?.slice(0, 16)); console.log('[cosigner] Peers:', node.peers.map(p => p.pubkey?.slice(0, 16)).join(', ')); }); - node.on('closed', () => console.log('[cosigner] Node closed')); + node.on('closed', () => { + if (shuttingDown) { + console.log('[cosigner] Node closed'); + return; + } + console.error('[cosigner] Node closed unexpectedly'); + process.exit(1); + }); node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); node.on('bounced', (...args) => console.log('[cosigner] Bounced:', safeStringify(args).slice(0, 200))); node.on('message', (msg) => { @@ -106,6 +114,7 @@ try { } const shutdown = () => { + shuttingDown = true; try { node?.close?.(); } catch (e) { console.error('[cosigner] Error closing node:', e instanceof Error ? e.message : String(e)); } diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index c5347a2..c9b94b4 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -90,8 +90,18 @@ function requireNonEmptyString(value: string | undefined, errorMessage: string): return value; } +function validateTestNsecHex(raw: string): string { + const normalized = raw.trim(); + if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0 || normalized.length !== 64) { + throw new Error( + 'Invalid TEST_NSEC_HEX: expected a 32-byte private key encoded as exactly 64 hex characters.' + ); + } + return normalized.toLowerCase(); +} + // Defaults come from fixture for local CI; callers can still override via environment. -const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex; +const TEST_NSEC_HEX = validateTestNsecHex(process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex); const MISSING_SMOKE_CREDS_MESSAGE = 'Smoke admin credentials are required. Set SMOKE_ADMIN_SECRET, SMOKE_ADMIN_USERNAME, and ' + 'SMOKE_ADMIN_PASSWORD (or provide tests/e2e/smoke-test.local.json).'; @@ -112,8 +122,19 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +function ensurePrivateDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(dirPath, 0o700); + } catch {} +} + function writeState(state: SmokeTestState): void { - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + ensurePrivateDir(path.dirname(STATE_FILE)); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); + try { + fs.chmodSync(STATE_FILE, 0o600); + } catch {} process.env.SMOKE_STATE_FILE = STATE_FILE; } @@ -275,8 +296,8 @@ export default async function globalSetup(_config: FullConfig): Promise { console.warn('[setup] Skipping TMP_DIR cleanup outside os.tmpdir():', resolvedTmp); } } - fs.mkdirSync(TMP_DIR, { recursive: true }); - fs.mkdirSync(DB_PATH, { recursive: true }); + ensurePrivateDir(TMP_DIR); + ensurePrivateDir(DB_PATH); writeState(state); console.log('[setup] Generating FROSTR credentials...'); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index cddaefb..7ebb7e7 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -7,8 +7,10 @@ import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId } = state; +let state: SmokeTestState; +let baseUrl = ''; +let sessionId = ''; +let groupPubkeyHex = ''; async function withApi(fn: (api: APIRequestContext) => Promise): Promise { const api = await request.newContext({ baseURL: baseUrl }); @@ -19,6 +21,13 @@ async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + state = loadState(); + baseUrl = state.baseUrl; + sessionId = state.sessionId; + groupPubkeyHex = state.groupPubkeyHex; +}); + test.describe('Status – /api/status', () => { test('GET /api/status is publicly accessible without auth', async () => { // /api/status intentionally allows unauthenticated health checks @@ -91,7 +100,7 @@ test.describe('Peers – /api/peers', () => { expect(res.status()).toBe(200); const body = await res.json(); expect(body).toHaveProperty('pubkey'); - expect(body.pubkey).toBe(state.groupPubkeyHex); + expect(body.pubkey).toBe(groupPubkeyHex); expect(typeof body.threshold).toBe('number'); }); }); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 5cfc047..36b5ebd 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -24,6 +24,7 @@ const { baseUrl, sessionId, groupPubkeyHex } = state; // Valid 32-byte hex event IDs for signing const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const SIGNATURE_REGEX = /^[0-9a-f]{128}$/i; async function withApi(fn: (api: APIRequestContext) => Promise): Promise { const api = await request.newContext({ baseURL: baseUrl }); @@ -90,7 +91,7 @@ test.describe('Sign – /api/sign', () => { expect(body).toHaveProperty('signature'); expect(typeof body.signature).toBe('string'); // Schnorr signature = 64 bytes = 128 hex chars - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + expect(body.signature).toMatch(SIGNATURE_REGEX); }); }); @@ -113,7 +114,7 @@ test.describe('Sign – /api/sign', () => { const body = await res.json(); expect(body).toHaveProperty('id'); expect(body).toHaveProperty('signature'); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + expect(body.signature).toMatch(SIGNATURE_REGEX); }); }); @@ -126,7 +127,7 @@ test.describe('Sign – /api/sign', () => { }); expect(res.status()).toBe(200); const body = await res.json(); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + expect(body.signature).toMatch(SIGNATURE_REGEX); }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index deaa742..6199100 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -1,6 +1,6 @@ /** * UI event-log smoke tests. - * Signing operations performed in 04-sign.e2e.ts will have produced log entries. + * This suite seeds its own event-log entries in beforeAll. */ import { test, expect, request } from '@playwright/test'; @@ -23,9 +23,10 @@ async function withApi(fn: (api: APIRequestContext) => Promise): Promise { test.beforeAll(async () => { await withApi(async (api) => { + const runUniqueHex = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padStart(64, 'b').slice(0, 64); const seedRes = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, - data: { message: 'b'.repeat(64) }, + data: { message: runUniqueHex }, }); if (!seedRes.ok()) { throw new Error(`Failed to seed event log via /api/sign: ${seedRes.status()} ${await seedRes.text()}`); diff --git a/tests/routes/admin.whoami.session.spec.ts b/tests/routes/admin.whoami.session.spec.ts index a95d8ce..76761c0 100644 --- a/tests/routes/admin.whoami.session.spec.ts +++ b/tests/routes/admin.whoami.session.spec.ts @@ -28,7 +28,9 @@ describe('admin whoami with DB-backed session', () => { const sessionId = auth.createSession(1, '203.0.113.7') expect(sessionId).toBeString() - database.default.exec("UPDATE sessions SET last_access = datetime('now', '-1 day') WHERE id = '" + sessionId + "'") + database.default + .prepare("UPDATE sessions SET last_access = datetime('now', '-1 day') WHERE id = ?") + .run(sessionId) const req = new Request('http://localhost/api/admin/whoami', { headers: { diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index e6a17e8..f5e8667 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -31,7 +31,7 @@ function loadFixtureTestKeysetSecret(): string | undefined { const TEST_KEYSET_SECRET = normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? - loadFixtureTestKeysetSecret(); + normalizeOptionalEnv(loadFixtureTestKeysetSecret()); if (!TEST_KEYSET_SECRET) { throw new Error( 'TEST_KEYSET_SECRET (or TEST_NSEC_HEX) must be set, or tests/e2e/smoke-test-defaults.json must provide testNsecHex.' diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 6ba153a..1c38aaf 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -42,7 +42,10 @@ function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string const compact = raw.replace(/\s+/g, ' ').trim(); if (!compact) return '(empty)'; const redacted = compact - .replace(/(admin_secret|session_secret|password|api[_-]?key|token)\s*[:=]\s*["']?[^"'\s]+/ig, '$1=') + .replace( + /(["']?(?:admin_secret|session_secret|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, + '$1' + ) .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...(truncated)` : redacted; } From eb65308ce6c46b6026d1e73854557933715c367f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 11:56:45 -0600 Subject: [PATCH 21/32] fix: harden credential save and auth validation flows --- frontend/components/Configure.tsx | 37 ++++++++++++++----- .../components/ui/input-with-validation.tsx | 2 +- src/routes/auth.ts | 6 ++- src/routes/onboarding.ts | 20 ++++------ src/routes/user.ts | 11 ++++-- tests/e2e/state.ts | 3 +- 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index c7f9f32..df2ba17 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -556,19 +556,34 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav setIsGenerating(true); try { + const parseRelayList = (raw: string): string[] | null => { + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + const relays = parsed + .filter((relay): relay is string => typeof relay === 'string') + .map((relay) => relay.trim()) + .filter((relay) => relay.length > 0); + return relays.length > 0 ? relays : null; + } catch { + return null; + } + }; + const resolveRelaysToSave = (): string[] => { - if (Array.isArray(existingRelays) && existingRelays.length > 0) { - return existingRelays; + if (!isHeadlessMode) { + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0); + } + return ["wss://relay.primal.net"]; } + if (typeof advancedSettings.RELAYS === 'string' && advancedSettings.RELAYS.trim().length > 0) { - try { - const parsed = JSON.parse(advancedSettings.RELAYS); - if (Array.isArray(parsed) && parsed.every(relay => typeof relay === 'string') && parsed.length > 0) { - return parsed; - } - } catch { - // fall back to default relay list below - } + const parsedRelays = parseRelayList(advancedSettings.RELAYS); + if (parsedRelays) return parsedRelays; + } + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0); } return ["wss://relay.primal.net"]; }; @@ -594,6 +609,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav const detail = await response.text().catch(() => ''); throw new Error(detail || `Failed to save headless credentials (${response.status})`); } + setExistingRelays(relaysToSave); } else { // Database mode - save to user credentials // Preserve existing relays or use default if none exist @@ -616,6 +632,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav const detail = await response.text().catch(() => ''); throw new Error(detail || `Failed to save credentials (${response.status})`); } + setExistingRelays(relaysToSave); } setHasExistingCredentials(true); diff --git a/frontend/components/ui/input-with-validation.tsx b/frontend/components/ui/input-with-validation.tsx index 8401b27..08c58f9 100644 --- a/frontend/components/ui/input-with-validation.tsx +++ b/frontend/components/ui/input-with-validation.tsx @@ -2,7 +2,7 @@ import React, { useId } from 'react'; import { Input } from "./input"; import { cn } from "../../lib/utils"; -interface InputWithValidationProps extends Omit, 'onChange'> { +interface InputWithValidationProps extends Omit, 'onChange' | 'required'> { label?: string | React.ReactNode; value: string; onChange: (value: string) => void; diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 8e910a9..9649fcf 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1094,6 +1094,10 @@ export function handleLogout(req: Request): Response { 'Vary': mergedVary, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Session-ID', + }; + + const logoutHeaders = { + ...headers, 'Set-Cookie': `session=; HttpOnly; Path=/; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Strict; Max-Age=0` }; @@ -1127,7 +1131,7 @@ export function handleLogout(req: Request): Response { try { zeroizeVaultEntryAndDelete(sessionId) } catch {} } - return Response.json({ success: true }, { headers }); + return Response.json({ success: true }, { headers: logoutHeaders }); } // Authentication middleware wrapper (deprecated - use explicit auth parameters instead) diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index a25d975..0c88ade 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from 'crypto'; +import { createHash, timingSafeEqual } from 'crypto'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { ADMIN_SECRET, HEADLESS, SKIP_ADMIN_SECRET_VALIDATION } from '../const.js'; @@ -290,21 +290,15 @@ export async function validateAdminSecret(adminSecret: string | undefined): Prom try { // Coerce to string to prevent type errors const adminSecretStr = String(adminSecret); - const providedSecret = Buffer.from(adminSecretStr); - const expectedSecret = Buffer.from(ADMIN_SECRET); - - // Timing-safe comparison - if (providedSecret.length !== expectedSecret.length) { - return false; - } - - return timingSafeEqual(providedSecret, expectedSecret); + const providedDigest = createHash('sha256').update(adminSecretStr).digest(); + const expectedDigest = createHash('sha256').update(String(ADMIN_SECRET)).digest(); + return timingSafeEqual(providedDigest, expectedDigest); } catch { // On any error, perform dummy comparison to maintain consistent timing - const expectedSecret = Buffer.from(String(ADMIN_SECRET)); - const dummySecret = Buffer.alloc(expectedSecret.length); + const expectedDigest = createHash('sha256').update(String(ADMIN_SECRET)).digest(); + const dummyDigest = Buffer.alloc(expectedDigest.length); try { - timingSafeEqual(dummySecret, expectedSecret); + timingSafeEqual(dummyDigest, expectedDigest); } catch {} return false; } diff --git a/src/routes/user.ts b/src/routes/user.ts index aa27cc9..4d32ba8 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -283,10 +283,13 @@ export async function handleUserRoute( if ('relays' in body) { // Validate relays format - if (body.relays === null || - (Array.isArray(body.relays) && - body.relays.every((r: any) => typeof r === 'string' && isValidWebSocketUrl(r)))) { - updates.relays = body.relays; + if (body.relays === null) { + updates.relays = null; + } else if ( + Array.isArray(body.relays) && + body.relays.every((r: unknown): r is string => typeof r === 'string' && isValidWebSocketUrl(r)) + ) { + updates.relays = body.relays.map((relay: string) => relay.trim()); } else { return Response.json( { error: 'Invalid relay URLs. Must use ws:// or wss://' }, diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index 68a70d1..8e52078 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -37,8 +37,7 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ .nonempty('shareCredentials must contain at least one credential'), groupPubkeyHex: z .string() - .min(1, 'groupPubkeyHex must be non-empty') - .regex(/^([0-9a-fA-F]+)$/, 'groupPubkeyHex must be a hex string'), + .regex(/^[0-9a-fA-F]{64}$/, 'groupPubkeyHex must be exactly 64 hex characters'), adminUsername: z.string().min(1, 'adminUsername must be non-empty'), adminPassword: z.string().min(1, 'adminPassword must be non-empty'), adminSecret: z.string().min(1, 'adminSecret must be non-empty'), From a4ebdcb7181125e9a35c1bf3b8fc2741fd7f5f9d Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 16:25:27 -0600 Subject: [PATCH 22/32] chore: remove Playwright e2e suite from repo --- .gitignore | 1 - bun.lock | 11 +- llm/implementation/e2e-smoke-tests.md | 372 -------------- package.json | 7 - playwright.config.ts | 43 -- src/routes/nip04.ts | 6 +- src/routes/nip44.ts | 4 +- tests/e2e/cosigner.mjs | 126 ----- tests/e2e/global-setup.ts | 486 ------------------ tests/e2e/global-teardown.ts | 180 ------- tests/e2e/helpers.ts | 18 - tests/e2e/smoke-test-defaults.json | 3 - tests/e2e/specs/01-auth.e2e.ts | 129 ----- tests/e2e/specs/02-status-peers.e2e.ts | 119 ----- tests/e2e/specs/03-nip44-nip04.e2e.ts | 157 ------ tests/e2e/specs/04-sign.e2e.ts | 152 ------ tests/e2e/specs/05-admin.e2e.ts | 181 ------- tests/e2e/specs/06-event-log.e2e.ts | 110 ---- tests/e2e/specs/07-env.e2e.ts | 110 ---- tests/e2e/specs/08-ui.e2e.ts | 113 ---- tests/e2e/state.ts | 91 ---- tests/routes/env.db-mode.spec.ts | 28 +- ...{status-env.spec.ts => status-env.test.ts} | 0 23 files changed, 8 insertions(+), 2439 deletions(-) delete mode 100644 llm/implementation/e2e-smoke-tests.md delete mode 100644 playwright.config.ts delete mode 100644 tests/e2e/cosigner.mjs delete mode 100644 tests/e2e/global-setup.ts delete mode 100644 tests/e2e/global-teardown.ts delete mode 100644 tests/e2e/helpers.ts delete mode 100644 tests/e2e/smoke-test-defaults.json delete mode 100644 tests/e2e/specs/01-auth.e2e.ts delete mode 100644 tests/e2e/specs/02-status-peers.e2e.ts delete mode 100644 tests/e2e/specs/03-nip44-nip04.e2e.ts delete mode 100644 tests/e2e/specs/04-sign.e2e.ts delete mode 100644 tests/e2e/specs/05-admin.e2e.ts delete mode 100644 tests/e2e/specs/06-event-log.e2e.ts delete mode 100644 tests/e2e/specs/07-env.e2e.ts delete mode 100644 tests/e2e/specs/08-ui.e2e.ts delete mode 100644 tests/e2e/state.ts rename tests/routes/{status-env.spec.ts => status-env.test.ts} (100%) diff --git a/.gitignore b/.gitignore index 9945c7b..eda8834 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,6 @@ test-*.sh debug-*.js verify-*.md test-results/ -playwright-report/ # LLM files .claude diff --git a/bun.lock b/bun.lock index ce4e1de..f7af0d5 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,6 @@ "zod": "^3.25.76", }, "devDependencies": { - "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", @@ -196,8 +195,6 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -438,7 +435,7 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -620,10 +617,6 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], - "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], @@ -842,8 +835,6 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md deleted file mode 100644 index 19fcdc4..0000000 --- a/llm/implementation/e2e-smoke-tests.md +++ /dev/null @@ -1,372 +0,0 @@ -# E2E Smoke Test Suite (Playwright – DB Mode) - -Last verified: 2026-02-20 -Status snapshot: all Playwright smoke tests passing as of the verification date above. For current counts, run `npx playwright test --list`. - -## Purpose - -The Playwright smoke test suite exercises igloo-server end-to-end in **database mode** (the default, `HEADLESS=false`). It starts a real server process, spins up a live FROSTR co-signer, completes the full onboarding flow, and then runs two categories of tests: - -- **API project** (`01`–`07`): Pure HTTP request-context tests — no browser. Cover auth, status, peers, NIP-44, NIP-04, signing, admin, event log, and credential management. -- **UI project** (`08`): Headless Chrome browser tests. Cover the login page, tab navigation, and the Event Log section embedded in the Signer tab. - -## Running the Tests - -```bash -# Full suite (both projects) -npx playwright test - -# API-only (faster, no browser dependency) -npx playwright test --project=api - -# UI-only -npx playwright test --project=ui - -# Single spec file -npx playwright test tests/e2e/specs/04-sign.e2e.ts - -# HTML report (opens automatically after a run that had failures) -npx playwright show-report -``` - -Prerequisites: -- `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). -- `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). -- Keep port 18002 free when possible. `tests/e2e/global-setup.ts` calls `resolvePort()` and will usually fall back to a random free port if 18002 is busy, but hard-coded references can still break if the preferred port is unavailable. -- Admin credentials must be provided before running tests. Set `SMOKE_ADMIN_SECRET`, `SMOKE_ADMIN_USERNAME`, and `SMOKE_ADMIN_PASSWORD`, or provide `tests/e2e/smoke-test.local.json`; otherwise `tests/e2e/global-setup.ts` exits early. - -## File Structure - -```text -tests/e2e/ -├── global-setup.ts # Starts server + co-signer, completes onboarding, writes state.json -├── global-teardown.ts # SIGTERMs both processes, deletes temp dir -├── state.ts # loadState() helper — reads JSON written by global-setup -├── cosigner.mjs # Minimal FROSTR co-signer subprocess (node/ESM) -└── specs/ - ├── 01-auth.e2e.ts - ├── 02-status-peers.e2e.ts - ├── 03-nip44-nip04.e2e.ts - ├── 04-sign.e2e.ts - ├── 05-admin.e2e.ts - ├── 06-event-log.e2e.ts - ├── 07-env.e2e.ts - └── 08-ui.e2e.ts - -playwright.config.ts # Project definitions: "api" (01–07), "ui" (08) -``` - -## Global Setup (`global-setup.ts`) - -The setup runs **once** before all tests and does the following in order: - -### 1. Generate a 2-of-2 FROSTR keyset - -```typescript -const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); -``` - -A **2-of-2** (not 2-of-3) scheme is used deliberately: with exactly two shares, the one connected co-signer is always sufficient to reach threshold without any ambiguity about which peer is needed. `TEST_NSEC_HEX` is a fixed 32-byte private key so the keyset is deterministic across runs. - -### 2. Start igloo-server - -The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, logFile)`. Key env overrides: - -| Variable | Value | Reason | -|---|---|---| -| `HOST_PORT` | `18002` | Fixed test port | -| `HOST_NAME` | `127.0.0.1` | Loopback only | -| `ADMIN_SECRET` | from env/fixture (e.g. `$SMOKE_ADMIN_SECRET`) | Loaded from environment or local fixture — do not commit secrets | -| `DB_PATH` | `$TMPDIR/igloo-smoke-test/db` | Fresh DB per run | -| `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | -| `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | -| `ALLOW_LOCALHOST_RELAY` | `true` | Allow `ws://127.0.0.1:18002` as a relay URL | -| `FROSTR_SIGN_TIMEOUT` | `5000` | Cap setup/sign probe latency to 5 s per request | -| `GROUP_CRED` | `''` | Clear any `.env` credential interference | -| `SHARE_CRED` | `''` | Clear any `.env` credential interference | -| `RELAYS` | `''` | Clear any `.env` relay interference | -| `NODE_ENV` | `test` | Suppresses some production-only behaviors | - -**Critical**: Bun automatically loads `.env` from the current directory into `process.env`. If the developer's `.env` contains stale `GROUP_CRED`/`SHARE_CRED`/`RELAYS` values from a different port, the server would connect its bifrost node to the wrong relay, making signing always time out. The empty-string overrides above force those variables to be blank regardless of what `.env` contains. - -### 3. Complete onboarding - -```text -POST /api/onboarding/validate-admin (Bearer ADMIN_SECRET) -POST /api/onboarding/setup (creates admin user with username + password) -POST /api/auth/login → sessionId -``` - -### 4. Set FROSTR credentials - -```text -POST /api/user/credentials { group_cred, share_cred: shareCredentials[0], relays: ['ws://127.0.0.1:18002'] } -``` - -In DB mode, credentials are stored per-user encrypted in SQLite. The server then creates the in-memory bifrost node and polls `GET /api/status` until `nodeActive === true`. - -### 5. Start co-signer - -```bash -node tests/e2e/cosigner.mjs ws://127.0.0.1:18002 -``` - -`cosigner.mjs` creates a bifrost node using `@frostr/igloo-core` directly (no igloo-cli TUI). The server holds `shareCredentials[0]`; the co-signer holds `shareCredentials[1]`. Both connect to the server's built-in Nostr relay at `ws://127.0.0.1:18002`. - -### 6. Signing readiness probe - -Up to 5 attempts (3 s apart) to POST a 32-byte hex message to `/api/sign`. Success confirms the threshold is reachable and the relay subscription is active on both sides. Setup aborts if signing never succeeds. - -### 7. Create a persistent API key - -`POST /api/admin/api-keys { label: 'smoke-test-key' }` — the returned token is saved in `state.json` as `apiKey` and used in tests that verify API key authentication. - -### 8. Write shared state - -Everything is serialized to `$TMPDIR/igloo-smoke-test/state.json` and the path is exported as `SMOKE_STATE_FILE`. Every spec file calls `loadState()` at module level to read this file. - -## Shared State (`state.ts`) - -`loadState()` reads `SMOKE_STATE_FILE`. During Playwright's test discovery phase (when `--list` is run or the config is imported without a server running) `SMOKE_STATE_FILE` is not set, so the function returns a harmless stub with empty strings. Tests only execute after global-setup has populated the real state. - -```typescript -interface SmokeTestState { - port: number; - baseUrl: string; - tmpDir: string; - serverPid: number; - cosignerPid: number; - sessionId: string; // live admin session from global-setup login - apiKey: string | null; // DB-backed API key token - apiKeyId: string | null; - groupCredential: string; - shareCredentials: string[]; // [0] = server share, [1] = cosigner share - groupPubkeyHex: string; // x-only (no 02/03 prefix) - adminUsername: string; - adminPassword: string; - adminSecret: string; -} -``` - -## Spec Coverage - -### `01-auth.e2e.ts` — Authentication - -- `GET /api/auth/status` returns available auth methods -- `POST /api/auth/login` — valid credentials return `sessionId` -- `POST /api/auth/login` — wrong/unknown password returns 401 -- `GET /api/peers` — no auth returns 401 *(uses `/api/peers`, not `/api/status` — see design decisions below)* -- `GET /api/status` — valid session and API key (X-API-Key and Bearer formats) return 200 -- `GET /api/peers` — invalid API key returns 401 -- `POST /api/auth/logout` — invalidates session; subsequent `GET /api/peers` returns 401 - -### `02-status-peers.e2e.ts` — Status and Peers - -- `GET /api/status` — publicly accessible without auth (intentional design; returns 200) -- `GET /api/status` — with session returns full node info: `serverRunning`, `nodeActive`, `health`, `relayCount`, `timestamp` -- `GET /api/status` — health object has `isConnected`, `consecutiveConnectivityFailures` -- `GET /api/peers` — 401 without auth -- `GET /api/peers` — returns peer list with `peers`, `total`, `online` -- `GET /api/peers/group` — returns `pubkey` (matches `state.groupPubkeyHex`), `threshold` -- `GET /api/peers/self` — returns own share pubkey - -### `03-nip44-nip04.e2e.ts` — NIP-44 and NIP-04 Encryption - -NIP-44: -- 401 without auth -- Encrypt returns ciphertext -- Encrypt → decrypt round-trips plaintext -- Invalid `peer_pubkey` returns 400 -- Missing `content` returns 400 - -NIP-04: -- 401 without auth -- Encrypt returns ciphertext with IV suffix (NIP-04 format: `?iv=`) -- Encrypt → decrypt round-trips plaintext -- Invalid `peer_pubkey` returns 400 - -Uses `state.groupPubkeyHex` as the peer pubkey for encryption (the server encrypts to itself for round-trip tests). - -### `04-sign.e2e.ts` — Threshold Signing - -- 401 without auth -- 400 for non-hex message -- 400 for message shorter than 32 bytes -- 400 for missing body -- Signs a 32-byte hex message; response contains `id` and `signature` -- Signs a full Nostr event object (with `id`, `pubkey`, `content`, `kind`, `created_at`, `tags`) -- Signs with API key auth (`X-API-Key` header) — confirms DB-backed API keys work for signing -- 400 for event with invalid pubkey - -Signing tests exercise the complete FROSTR threshold flow: server publishes a sign request over the relay, co-signer responds with a partial signature, server aggregates and returns the final signature. - -### `05-admin.e2e.ts` — Admin Endpoints - -API key management: -- `GET /api/admin/api-keys` returns list (includes key from global-setup) -- `POST /api/admin/api-keys` creates a key (returns 201 with `token`, `id`) -- New API key authenticates successfully -- Revoked API key returns 401: creates key → verify works on `/api/event-log` → revoke → verify 401 on `/api/event-log` - -User management: -- `GET /api/admin/users` returns users list; admin user is present -- `GET /api/admin/whoami` returns `userId` -- Both require auth (401 without) - -### `06-event-log.e2e.ts` — UI Event Log - -- `GET /api/event-log` — 401 without auth -- Returns `{ entries: [...] }` with valid shape (`type`, `message`, `timestamp`) -- Pagination: `?limit=5` returns ≤ 5 entries -- `GET /api/event-log/export` — streams NDJSON (`Content-Type: application/x-ndjson`); each line parses as valid JSON -- Export — 401 without auth - -### `07-env.e2e.ts` — Credential / Env Management - -- `GET /api/env` — 401 without auth -- `GET /api/env` with session — returns `{ hasCredentials: true, ... }` -- `POST /api/env` — invalid `GROUP_CRED` returns 400 -- `POST /api/env` — invalid `SHARE_CRED` returns 400 -- `POST /api/env` — invalid relay URL returns 400 -- `POST /api/env` — without auth returns 401 - -### `08-ui.e2e.ts` — Browser UI (Headless Chrome) - -Login page: -- `/` renders login form (username + password inputs visible) -- Login form fills credentials and reaches the dashboard (tabs visible) - -Authenticated app (each test logs in fresh via `beforeEach`): -- Signer tab is visible after login -- Configure tab is accessible (click navigates, inputs render) -- API Keys tab renders without "Something went wrong" -- Event Log collapsible section (inside Signer tab, not a separate tab) is visible, click-to-expand works, no errors -- Logout button signs out and returns to login form - -Onboarding: -- `/` does not show "Admin Secret" text when DB is already initialized - -## Design Decisions and Gotchas - -### `/api/status` is intentionally public - -`/api/status` bypasses the main authentication check in `src/routes/index.ts`: - -```typescript -const isStatusEndpoint = url.pathname === '/api/status'; -// Auth check skips status: -if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && ...) { -``` - -This is by design — unauthenticated health checks and monitoring probes must be able to reach the status endpoint. Consequently: -- Tests that verify 401 enforcement **must use a different endpoint** (e.g., `GET /api/peers` or `GET /api/event-log`). -- Tests that verify authenticated 200 responses can still use `/api/status` (they pass with or without auth). - -### DB API keys and per-user credential lookup - -Database-backed API keys authenticate via `authenticateDatabaseApiKey()` and return `userId: 'api-key:'` — a string, not a numeric DB row ID. Several routes in DB mode call `getCredentials(auth)` which requires a numeric `userId` to decrypt per-user credentials from SQLite. If `userId` is not numeric, `getCredentials` returns `null` and the route responds 401. - -Affected routes: `GET /api/peers`, `GET /api/peers/group`, `GET /api/peers/self`. -Unaffected: `POST /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). - -For this reason: -- The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. -- The "new API key can authenticate" test uses `GET /api/event-log` to exercise real API-key auth enforcement on a protected endpoint. - -### Event log export is NDJSON, not JSON - -`GET /api/event-log/export` returns `Content-Type: application/x-ndjson` with one JSON object per line (newline-delimited JSON). Calling `response.json()` on this response fails because the body as a whole is not valid JSON. The test reads the body as text and parses each line individually: - -```typescript -const text = await res.text(); -const lines = text.trim().split('\n').filter(Boolean); -for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); -} -``` - -### `POST /api/env` validates credential format in DB mode - -The DB-mode `POST /api/env` handler (in `src/routes/env.ts`) validates `GROUP_CRED` and `SHARE_CRED` using `validateGroup()` / `validateShare()` from `@frostr/igloo-core` before writing to the `.env` file. Invalid credentials return 400. This validation was added during test development; it was previously only present on the headless `/api/env/shares` path. - -### Event Log is embedded in Signer tab, not a top-level tab - -The application has four top-level tabs: **Signer**, **NIP-46**, **API Keys**, **Recover**. There is no "Event Log" tab. The event log is a collapsible section within the Signer tab rendered as a `div[role="button"]` containing a `Event Log`. The UI test locates it with: - -```typescript -page.locator('[role="button"]:has-text("Event Log")').first() -``` - -### `.env` file interference with test server - -Bun loads `.env` from the current working directory automatically. A developer's `.env` may contain `GROUP_CRED`, `SHARE_CRED`, or `RELAYS` pointing to a production relay or a different port. If these leak into the test server's environment, the server creates a bifrost node at startup using the old credentials (different relay URL), and when the test then POSTs new credentials the server logs "Node already running, skipping restart" and stays connected to the wrong relay. The co-signer connects to the test relay, the server connects elsewhere — signing always times out. - -**Fix**: global-setup passes explicit empty-string overrides for all three variables when spawning the server: -```typescript -GROUP_CRED: '', -SHARE_CRED: '', -RELAYS: '', -``` - -### nostr-tools 2.x REQ filter format - -`@frostr/igloo-core` (which depends on `nostr-tools` 2.x) sends REQ messages in the format: -```json -["REQ", "sub_id", [{"kinds":[20004],"#p":[""]}]] -``` -Note the **array-wrapped filter** as the third element. NIP-01 expects filters as positional arguments: -```json -["REQ", "sub_id", {"kinds":[20004],"#p":[""]}] -``` - -The built-in relay (`src/class/relay.ts`) normalizes this in `_handler`: -```typescript -if (payload.length === 2 && Array.isArray(payload[1])) { - payload = [payload[0], ...payload[1]]; -} -``` - -Without this fix, the server's relay would reject all subscriptions from the bifrost node (logging "bad req: provided filter is not an object") and signing would always time out. - -## Temp Directory Layout - -Each run creates a fresh temp directory at `$TMPDIR/igloo-smoke-test/` (deleted by teardown): - -```text -igloo-smoke-test/ -├── db/ # SQLite database files (igloo.db, .session-secret) -├── state.json # Shared test state (pids, session, credentials, etc.) -├── server.log # igloo-server stdout/stderr -└── cosigner.log # co-signer subprocess stdout/stderr -``` - -If a run fails unexpectedly (e.g., setup throws before teardown registers), the temp dir may be left behind. It is safe to delete manually. - -## Adding New Tests - -1. Create `tests/e2e/specs/NN-name.e2e.ts`. -2. Import `loadState` from `../state.js` and call it at module level. -3. Use `state.sessionId` for session-authenticated requests, `state.apiKey` for API key requests. -4. Add the spec to the correct project in `playwright.config.ts` (update `testMatch` if needed, or rely on the `0[1-7]-*.e2e.ts` glob for API specs). -5. If testing a credential-sensitive endpoint in DB mode (peers, env), use `state.sessionId` — DB API keys cannot look up per-user credentials. - -## Files - -| File | Role | -|---|---| -| `tests/e2e/global-setup.ts` | Server lifecycle, onboarding, state serialization | -| `tests/e2e/global-teardown.ts` | SIGTERM + temp dir cleanup | -| `tests/e2e/state.ts` | `SmokeTestState` type and `loadState()` | -| `tests/e2e/cosigner.mjs` | Minimal co-signer subprocess (ESM, no TUI) | -| `tests/e2e/specs/01-auth.e2e.ts` | Auth enforcement, login/logout | -| `tests/e2e/specs/02-status-peers.e2e.ts` | Node status, peer list | -| `tests/e2e/specs/03-nip44-nip04.e2e.ts` | NIP-44 / NIP-04 encrypt+decrypt | -| `tests/e2e/specs/04-sign.e2e.ts` | Threshold Schnorr signing | -| `tests/e2e/specs/05-admin.e2e.ts` | API key CRUD, revocation, user management | -| `tests/e2e/specs/06-event-log.e2e.ts` | Event log pagination and NDJSON export | -| `tests/e2e/specs/07-env.e2e.ts` | Credential/env endpoint validation | -| `tests/e2e/specs/08-ui.e2e.ts` | Headless Chrome SPA smoke tests | -| `playwright.config.ts` | Project config, timeout, reporter, globalSetup/Teardown | -| `src/routes/env.ts` | DB-mode POST validates GROUP_CRED / SHARE_CRED format | -| `src/class/relay.ts` | Normalizes nostr-tools 2.x double-wrapped REQ filters | -| `src/routes/utils.ts` | `ALLOW_LOCALHOST_RELAY` bypass for test relay URLs | diff --git a/package.json b/package.json index 51368fb..9fa9da0 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,6 @@ "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", "test:unit": "bun test --max-concurrency=1 src tests/routes", - "test:e2e:smoke": "npx playwright test --project=api tests/e2e/specs/01-auth.e2e.ts tests/e2e/specs/04-sign.e2e.ts tests/e2e/specs/05-admin.e2e.ts", - "test:e2e": "npx playwright test", - "test:e2e:nightly": "npx playwright test --project=api --project=ui --retries=2 --timeout=60000", - "test:e2e:ui": "npx playwright test --project=ui", - "test:e2e:api": "npx playwright test --project=api", - "test:e2e:report": "npx playwright show-report", "typecheck": "tsc --noEmit", "tsc": "tsc --noEmit" }, @@ -66,7 +60,6 @@ "zod": "^3.25.76" }, "devDependencies": { - "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 97b856f..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './tests/e2e/specs', - globalSetup: './tests/e2e/global-setup.ts', - globalTeardown: './tests/e2e/global-teardown.ts', - - // Run all tests sequentially – they share one live server + co-signer process - fullyParallel: false, - workers: 1, - - // Retry once on CI to absorb timing flakes - retries: process.env.CI ? 1 : 0, - - reporter: [ - ['list'], - ['html', { outputFolder: 'playwright-report', open: 'never' }], - ], - - use: { - trace: 'on-first-retry', - // Longer default for operations that wait on bifrost relay round-trips - actionTimeout: 15_000, - }, - - projects: [ - // Pure API specs – use request context only, no browser - { - name: 'api', - testMatch: ['**/[0-9][0-9]-*.e2e.ts'], - testIgnore: ['**/08-ui.e2e.ts'], - }, - // Browser UI spec (08) – needs a real browser - { - name: 'ui', - testMatch: ['**/08-ui.e2e.ts'], - use: { ...devices['Desktop Chrome'], headless: true }, - }, - ], - - // Global per-test timeout – sign tests can take up to 15 s - timeout: 30_000, -}); diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index 135543c..c9ab656 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -85,12 +85,14 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }) - // Separate bucket for e2e crypto ops + // Separate bucket for crypto operations const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10) + const retryAfter = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900 return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, - headers: { ...headers, 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() } + headers: { ...headers, 'Retry-After': retryAfter.toString() } }) } diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index b0d71ef..9c0e07c 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -34,8 +34,8 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }); - // Basic rate limit for e2e crypto ops - // Separate bucket for e2e crypto ops + // Basic rate limit for crypto operations + // Use a dedicated bucket separate from signing traffic. const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs deleted file mode 100644 index 9e655d7..0000000 --- a/tests/e2e/cosigner.mjs +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Minimal FROSTR co-signer for smoke tests. - * - * Usage: node cosigner.mjs - */ - -const [,, groupCred, shareCred, relayUrl] = process.argv; - -if (!groupCred || !shareCred || !relayUrl) { - console.error('Usage: cosigner.mjs '); - process.exit(1); -} - -const { - createBifrostNode, - connectNode, -} = await import('@frostr/igloo-core'); - -const CONNECT_TIMEOUT_MS_RAW = process.env.SMOKE_COSIGNER_CONNECT_TIMEOUT_MS ?? '20000'; -const parsedConnectTimeout = Number.parseInt(CONNECT_TIMEOUT_MS_RAW, 10); -const CONNECT_TIMEOUT_MS = Number.isFinite(parsedConnectTimeout) && parsedConnectTimeout > 0 - ? parsedConnectTimeout - : 20000; - -async function connectWithTimeout(nodeInstance, relay) { - let timeoutId; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error(`connectNode timeout after ${CONNECT_TIMEOUT_MS}ms for relay ${relay}`)); - }, CONNECT_TIMEOUT_MS); - }); - - const connectionPromise = connectNode(nodeInstance); - connectionPromise.catch(() => {}); - - try { - await Promise.race([connectionPromise, timeoutPromise]); - } finally { - if (timeoutId) clearTimeout(timeoutId); - } -} - -/** - * Serializes a value to JSON, replacing circular refs with "[Circular]" to avoid - * "Converting circular structure to JSON" TypeError from bubbling into outer catch. - * @param {unknown} obj - Value to serialize - * @returns {string} JSON string or fallback representation - */ -function safeStringify(obj) { - const seen = new WeakSet(); - function replacer(_key, value) { - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) return '[Circular]'; - seen.add(value); - } - return value; - } - try { - return JSON.stringify(obj, replacer); - } catch (e) { - return '[Non-serializable]'; - } -} - -let node; -let shuttingDown = false; -try { - node = createBifrostNode({ - group: groupCred, - share: shareCred, - relays: [relayUrl], - }, { enableLogging: false }); - - node.on('ready', () => { - console.log('[cosigner] Node ready. PubKey:', node.pubkey?.slice(0, 16)); - console.log('[cosigner] Peers:', node.peers.map(p => p.pubkey?.slice(0, 16)).join(', ')); - }); - node.on('closed', () => { - if (shuttingDown) { - console.log('[cosigner] Node closed'); - return; - } - console.error('[cosigner] Node closed unexpectedly'); - process.exit(1); - }); - node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); - node.on('bounced', (...args) => console.log('[cosigner] Bounced:', safeStringify(args).slice(0, 200))); - node.on('message', (msg) => { - console.log('[cosigner] Message tag:', msg?.tag, '| from:', msg?.env?.pubkey?.slice(0,16)); - }); - node.on('/sign/handler/req', (msg) => console.log('[cosigner] SIGN REQ received, id:', msg?.id)); - node.on('/sign/handler/res', () => console.log('[cosigner] SIGN RES sent')); - node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', safeStringify(a).slice(0, 200))); - - // Also spy on the raw WebSocket to confirm relay subscription - node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', safeStringify(a).slice(0, 100))); - - console.log('[cosigner] Connecting to relay:', relayUrl); - await connectWithTimeout(node, relayUrl); - console.log('[cosigner] Connected. Pubkey:', node.pubkey); - const filter = node.client?.filter; - if (filter !== undefined) { - console.log('[cosigner] Filter (public):', safeStringify(filter)); - } else { - // TODO: Track upstream accessor support in @frostr/igloo-core if filter visibility is needed. - console.warn('[cosigner] Filter unavailable on node.client.filter (public accessor missing)'); - } - -} catch (err) { - const message = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''; - console.error(`[cosigner] Failed to start: ${message}${stack}`); - process.exit(2); -} - -const shutdown = () => { - shuttingDown = true; - try { node?.close?.(); } catch (e) { - console.error('[cosigner] Error closing node:', e instanceof Error ? e.message : String(e)); - } - process.exit(0); -}; -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); - -setInterval(() => {}, 60_000); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts deleted file mode 100644 index c9b94b4..0000000 --- a/tests/e2e/global-setup.ts +++ /dev/null @@ -1,486 +0,0 @@ -/** - * Playwright global setup for DB-mode smoke tests. - * - * What this does: - * 1. Generate a deterministic 2-of-2 FROSTR keyset. - * 2. Start igloo-server against a fresh temporary DB path. - * 3. Complete onboarding and login. - * 4. Persist user credentials to start the Bifrost node. - * 5. Start a real co-signer process. - * 6. Probe signing readiness. - * 7. Create a reusable API key for auth tests. - * 8. Persist shared state for specs and teardown. - */ - -import { request } from '@playwright/test'; -import type { APIRequestContext, FullConfig } from '@playwright/test'; -import { spawn } from 'child_process'; -import type { ChildProcess } from 'child_process'; -import fs from 'fs'; -import net from 'net'; -import os from 'os'; -import path from 'path'; -import type { SmokeTestState } from './state.js'; - -const REQUESTED_PORT_RAW = process.env.SMOKE_TEST_PORT ?? '18002'; -const REQUESTED_PORT = Number.parseInt(REQUESTED_PORT_RAW, 10); -const DEFAULT_PORT = Number.isFinite(REQUESTED_PORT) && REQUESTED_PORT > 0 ? REQUESTED_PORT : 18002; -const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -const TMP_DIR = process.env.SMOKE_TEST_TMP_DIR ?? path.join(os.tmpdir(), `igloo-smoke-test-${RUN_ID}`); -const STATE_FILE = path.join(TMP_DIR, 'state.json'); -const DB_PATH = path.join(TMP_DIR, 'db'); -const SERVER_LOG = path.join(TMP_DIR, 'server.log'); -const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); - -const smokeDefaultsPath = path.resolve('tests/e2e/smoke-test-defaults.json'); -const smokeDefaultsRaw: unknown = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); -if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { - throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); -} -const raw = smokeDefaultsRaw as Record; -if (typeof raw.testNsecHex !== 'string' || raw.testNsecHex.trim().length === 0) { - throw new Error( - 'smoke-test-defaults.json is missing required non-empty string property: testNsecHex.', - ); -} -const smokeDefaults = raw as { - testNsecHex: string; -}; - -function loadOptionalLocalSmokeCredentials(): Partial<{ - adminSecret: string; - adminUsername: string; - adminPassword: string; -}> { - const localFixturePath = - process.env.SMOKE_LOCAL_FIXTURE_PATH?.trim() || - path.resolve('tests/e2e/smoke-test.local.json'); - if (!fs.existsSync(localFixturePath)) { - return {}; - } - try { - const localRaw: unknown = JSON.parse(fs.readFileSync(localFixturePath, 'utf8')); - if (typeof localRaw !== 'object' || localRaw === null) { - throw new Error('expected JSON object'); - } - const fixture = localRaw as Record; - return { - adminSecret: typeof fixture.adminSecret === 'string' && fixture.adminSecret.trim().length > 0 - ? fixture.adminSecret - : undefined, - adminUsername: typeof fixture.adminUsername === 'string' && fixture.adminUsername.trim().length > 0 - ? fixture.adminUsername - : undefined, - adminPassword: typeof fixture.adminPassword === 'string' && fixture.adminPassword.trim().length > 0 - ? fixture.adminPassword - : undefined, - }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse local smoke fixture at ${localFixturePath}: ${detail}`); - } -} - -const localSmokeCredentials = loadOptionalLocalSmokeCredentials(); - -function requireNonEmptyString(value: string | undefined, errorMessage: string): string { - if (!value || value.trim().length === 0) { - throw new Error(errorMessage); - } - return value; -} - -function validateTestNsecHex(raw: string): string { - const normalized = raw.trim(); - if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0 || normalized.length !== 64) { - throw new Error( - 'Invalid TEST_NSEC_HEX: expected a 32-byte private key encoded as exactly 64 hex characters.' - ); - } - return normalized.toLowerCase(); -} - -// Defaults come from fixture for local CI; callers can still override via environment. -const TEST_NSEC_HEX = validateTestNsecHex(process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex); -const MISSING_SMOKE_CREDS_MESSAGE = - 'Smoke admin credentials are required. Set SMOKE_ADMIN_SECRET, SMOKE_ADMIN_USERNAME, and ' + - 'SMOKE_ADMIN_PASSWORD (or provide tests/e2e/smoke-test.local.json).'; -const ADMIN_SECRET = requireNonEmptyString( - process.env.SMOKE_ADMIN_SECRET ?? process.env.ADMIN_SECRET ?? localSmokeCredentials.adminSecret, - MISSING_SMOKE_CREDS_MESSAGE -); -const ADMIN_USERNAME = requireNonEmptyString( - process.env.SMOKE_ADMIN_USERNAME ?? process.env.ADMIN_USERNAME ?? localSmokeCredentials.adminUsername, - MISSING_SMOKE_CREDS_MESSAGE -); -const ADMIN_PASSWORD = requireNonEmptyString( - process.env.SMOKE_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD ?? localSmokeCredentials.adminPassword, - MISSING_SMOKE_CREDS_MESSAGE -); - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function ensurePrivateDir(dirPath: string): void { - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - try { - fs.chmodSync(dirPath, 0o700); - } catch {} -} - -function writeState(state: SmokeTestState): void { - ensurePrivateDir(path.dirname(STATE_FILE)); - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); - try { - fs.chmodSync(STATE_FILE, 0o600); - } catch {} - process.env.SMOKE_STATE_FILE = STATE_FILE; -} - -function terminateProcess(proc: ChildProcess | null, label: string): void { - if (!proc?.pid) return; - try { - process.kill(proc.pid, 'SIGTERM'); - console.log(`[setup] Sent SIGTERM to ${label} (pid ${proc.pid})`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { - console.warn(`[setup] Could not stop ${label} (pid ${proc.pid})`, err); - } - } -} - -// Port probing is inherently TOCTOU: we can only test availability now, not reserve it -// forever. For CI smoke tests this low-probability race is acceptable. -function canBindPort(port: number, host: string): Promise { - return new Promise(resolve => { - const srv = net.createServer(); - srv.once('error', () => resolve(false)); - srv.listen(port, host, () => { - srv.close(() => resolve(true)); - }); - }); -} - -// Reserve an ephemeral port by binding to :0 and immediately closing; another process -// could still claim it before spawn, but this is sufficient for smoke test setup. -function reserveRandomPort(host: string): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.once('error', reject); - srv.listen(0, host, () => { - const addr = srv.address(); - const port = typeof addr === 'object' && addr ? addr.port : 0; - srv.close(() => resolve(port)); - }); - }); -} - -// Prefer the requested port, but fall back when busy. This does not eliminate the -// bind race between probing and process startup. -async function resolvePort(host: string, preferredPort: number): Promise { - if (await canBindPort(preferredPort, host)) { - return preferredPort; - } - const fallbackPort = await reserveRandomPort(host); - if (!Number.isInteger(fallbackPort) || fallbackPort < 1 || fallbackPort > 65535) { - throw new Error( - `[setup] Failed to reserve a valid fallback port after preferred port ${preferredPort} was busy (got: ${fallbackPort})`, - ); - } - console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort} (probe-close race still applies)`); - return fallbackPort; -} - -async function pollUntil( - fn: () => Promise, - timeoutMs: number, - intervalMs = 1000, - label = 'condition', -): Promise { - const deadline = Date.now() + timeoutMs; - let consecutiveFailures = 0; - while (Date.now() < deadline) { - try { - if (await fn()) return; - consecutiveFailures = 0; - } catch (error) { - consecutiveFailures += 1; - const nearingDeadline = Date.now() + intervalMs >= deadline; - if (consecutiveFailures === 3 || nearingDeadline) { - const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); - console.warn(`[setup] pollUntil(${label}) transient failure x${consecutiveFailures}: ${detail}`); - } - } - await sleep(intervalMs); - } - throw new Error(`Timed out waiting for: ${label}`); -} - -async function waitForHttp(url: string, timeoutMs: number): Promise { - await pollUntil( - async () => { - const res = await fetch(url).catch(() => null); - return res !== null && res.status < 500; - }, - timeoutMs, - 500, - `HTTP ${url}`, - ); -} - -function spawnDetached( - cmd: string, - args: string[], - env: NodeJS.ProcessEnv, - logFile: string, -): ChildProcess { - const out = fs.openSync(logFile, 'a'); - try { - const proc = spawn(cmd, args, { - env: { ...process.env, ...env }, - detached: false, - stdio: ['ignore', out, out], - }); - proc.on('error', err => { - fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); - }); - return proc; - } finally { - fs.closeSync(out); - } -} - -export default async function globalSetup(_config: FullConfig): Promise { - const host = '127.0.0.1'; - const port = await resolvePort(host, DEFAULT_PORT); - const baseUrl = `http://${host}:${port}`; - - const state: SmokeTestState = { - port, - baseUrl, - tmpDir: TMP_DIR, - serverPid: 0, - cosignerPid: 0, - sessionId: '', - apiKey: null, - apiKeyId: null, - groupCredential: '', - shareCredentials: [], - groupPubkeyHex: '', - adminUsername: ADMIN_USERNAME, - adminPassword: ADMIN_PASSWORD, - adminSecret: ADMIN_SECRET, - }; - - let api: APIRequestContext | null = null; - let serverProcess: ChildProcess | null = null; - let cosignerProcess: ChildProcess | null = null; - - process.env.SMOKE_STATE_FILE = STATE_FILE; - - try { - const resolvedTmp = path.resolve(TMP_DIR); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); - - if (fs.existsSync(TMP_DIR)) { - if (isInsideTemp) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } else { - console.warn('[setup] Skipping TMP_DIR cleanup outside os.tmpdir():', resolvedTmp); - } - } - ensurePrivateDir(TMP_DIR); - ensurePrivateDir(DB_PATH); - writeState(state); - - console.log('[setup] Generating FROSTR credentials...'); - const iglooCore = await import('@frostr/igloo-core') as typeof import('@frostr/igloo-core'); - const { generateKeysetWithSecret, decodeGroup } = iglooCore; - - const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); - if ( - !Array.isArray(shareCredentials) || - shareCredentials.length < 2 || - typeof shareCredentials[0] !== 'string' || - typeof shareCredentials[1] !== 'string' - ) { - throw new Error( - `Invalid keyset from generateKeysetWithSecret: shareCredentials.length=${Array.isArray(shareCredentials) ? shareCredentials.length : 'non-array'} ` + - `shareCredentials=${JSON.stringify(shareCredentials)} groupCredentialType=${typeof groupCredential}` - ); - } - const group = decodeGroup(groupCredential); - const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); - state.groupCredential = groupCredential; - state.shareCredentials = shareCredentials; - state.groupPubkeyHex = groupPubkeyHex; - writeState(state); - - console.log('[setup] Starting igloo-server on port', port, '...'); - serverProcess = spawnDetached( - 'bun', - ['run', 'src/server.ts'], - { - ADMIN_SECRET, - DB_PATH, - HOST_PORT: String(port), - HOST_NAME: host, - RATE_LIMIT_ENABLED: 'false', - SKIP_RELAY_PROBE: 'true', - SKIP_STARTUP_ECHO: 'true', - NODE_ENV: 'test', - AUTH_ENABLED: 'true', - FROSTR_SIGN_TIMEOUT: '5000', - UI_EVENT_LOG_INCLUDE_PINGS: 'false', - UPDATE_CHECK_DISABLED: 'true', - ALLOW_LOCALHOST_RELAY: 'true', - // Clear any .env credentials so the server starts without pre-loaded creds - GROUP_CRED: '', - SHARE_CRED: '', - RELAYS: '', - }, - SERVER_LOG, - ); - state.serverPid = serverProcess.pid ?? 0; - writeState(state); - - await waitForHttp(`${baseUrl}/api/onboarding/status`, 20_000); - console.log('[setup] Server is up.'); - - console.log('[setup] Running onboarding...'); - api = await request.newContext({ baseURL: baseUrl }); - - let res = await api.post('/api/onboarding/validate-admin', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - }); - if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); - - res = await api.post('/api/onboarding/setup', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); - - console.log('[setup] Logging in...'); - res = await api.post('/api/auth/login', { - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); - const { sessionId } = (await res.json()) as { sessionId: string }; - state.sessionId = sessionId; - writeState(state); - - console.log('[setup] Setting FROSTR credentials on server...'); - res = await api.post('/api/user/credentials', { - headers: { 'X-Session-ID': sessionId }, - data: { - group_cred: groupCredential, - share_cred: shareCredentials[0], - relays: [`ws://${host}:${port}`], - }, - }); - if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); - - await pollUntil( - async () => { - const s = await api!.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); - if (!s.ok()) return false; - const body = (await s.json()) as { nodeActive: boolean }; - return body.nodeActive === true; - }, - 15_000, - 1000, - 'nodeActive = true', - ); - console.log('[setup] Node is active.'); - - console.log('[setup] Starting co-signer with shareCredentials[1]...'); - cosignerProcess = spawnDetached( - 'node', - [ - path.resolve('tests/e2e/cosigner.mjs'), - groupCredential, - shareCredentials[1], - `ws://${host}:${port}`, - ], - {}, - COSIGNER_LOG, - ); - state.cosignerPid = cosignerProcess.pid ?? 0; - writeState(state); - console.log('[setup] Co-signer pid:', cosignerProcess.pid); - - console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); - const TEST_MSG = 'a'.repeat(64); - let signOk = false; - for (let attempt = 1; attempt <= 5; attempt++) { - if (cosignerProcess && cosignerProcess.exitCode !== null) { - const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error( - `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` - ); - } - await sleep(3000); - const sr = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: TEST_MSG }, - }).catch(() => null); - if (sr && sr.ok()) { - signOk = true; - console.log(`[setup] Signing OK on attempt ${attempt}.`); - break; - } - const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; - console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); - } - if (!signOk) { - const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error(`Co-signer did not become ready within probe window.\nCo-signer log:\n${cosLog}`); - } - - console.log('[setup] Creating test API key...'); - res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'smoke-test-key' }, - }); - if (res.ok()) { - const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; - state.apiKey = body.apiKey.token; - state.apiKeyId = String(body.apiKey.id); - } else { - console.warn('[setup] Could not create API key - auth tests will skip API-key checks.'); - } - - await api.dispose(); - api = null; - - writeState(state); - console.log('[setup] Global setup complete. State saved to', STATE_FILE); - } catch (err) { - if (api) { - try { - await api.dispose(); - } catch { - // no-op - } - } - - terminateProcess(cosignerProcess, 'co-signer'); - terminateProcess(serverProcess, 'server'); - - // Persist whatever we have so teardown can still clean up. - try { - writeState(state); - } catch { - // no-op - } - throw err; - } -} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts deleted file mode 100644 index 071cf3b..0000000 --- a/tests/e2e/global-teardown.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Playwright global teardown – kills the server + co-signer and cleans up - * the temp directory created by global-setup. - */ - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { execFileSync } from 'child_process'; -import type { FullConfig } from '@playwright/test'; -import type { SmokeTestState } from './state.js'; - -const MAX_STATE_AGE_MS = 10 * 60 * 1000; - -function parsePositivePid(raw: unknown, fieldName: string): number | null { - if (raw == null || raw === 0) return null; - if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) { - console.warn(`[teardown] Invalid ${fieldName}; expected positive integer PID, got:`, raw); - return null; - } - return raw; -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'EPERM') return true; - return false; - } -} - -function getProcessCommand(pid: number): string | null { - try { - const output = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - const cmd = output.trim(); - return cmd.length > 0 ? cmd : null; - } catch { - return null; - } -} - -function validateProcessIdentity(pid: number, label: 'co-signer' | 'server'): boolean { - const command = getProcessCommand(pid); - if (!command) { - console.warn(`[teardown] Could not read command line for ${label} pid ${pid}; skipping SIGTERM for safety.`); - return false; - } - - const expectedFragment = label === 'co-signer' ? 'tests/e2e/cosigner.mjs' : 'src/server.ts'; - const matches = command.includes(expectedFragment); - if (!matches) { - console.warn( - `[teardown] ${label} pid ${pid} command did not match expected identity (${expectedFragment}); ` + - `actual="${command}". Skipping SIGTERM for safety.` - ); - } - return matches; -} - -function resolveSafeTmpDir(rawTmpDir: unknown): string | null { - if (typeof rawTmpDir !== 'string' || rawTmpDir.trim().length === 0) { - console.warn('[teardown] Invalid tmpDir in state; skipping temp cleanup.'); - return null; - } - const resolvedTmp = path.resolve(rawTmpDir); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); - if (!isInsideTemp) { - console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); - return null; - } - if (!path.basename(resolvedTmp).startsWith('igloo-smoke-test-')) { - console.warn('[teardown] Refusing to remove unexpected temp dir name:', resolvedTmp); - return null; - } - try { - if (!fs.existsSync(resolvedTmp)) { - console.warn('[teardown] tmpDir does not exist; skipping temp cleanup:', resolvedTmp); - return null; - } - if (!fs.statSync(resolvedTmp).isDirectory()) { - console.warn('[teardown] tmpDir is not a directory; skipping temp cleanup:', resolvedTmp); - return null; - } - } catch (error) { - console.warn('[teardown] Could not validate tmpDir; skipping temp cleanup:', error); - return null; - } - return resolvedTmp; -} - -export default async function globalTeardown(_config: FullConfig): Promise { - const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile || stateFile.trim().length === 0) { - throw new Error('[teardown] SMOKE_STATE_FILE is required; refusing to guess a state file.'); - } - - const resolvedStateFile = path.resolve(stateFile); - console.log('[teardown] Resolved state file:', resolvedStateFile); - - if (!fs.existsSync(resolvedStateFile)) { - throw new Error(`[teardown] State file does not exist: ${resolvedStateFile}`); - } - - let skipTempCleanup = false; - try { - const ageMs = Date.now() - fs.statSync(resolvedStateFile).mtimeMs; - if (ageMs > MAX_STATE_AGE_MS) { - console.warn( - `[teardown] State file is stale (${Math.round(ageMs / 1000)}s old); ` + - 'skipping temp cleanup, but continuing process teardown to avoid leaks.' - ); - skipTempCleanup = true; - } - } catch (error) { - console.warn('[teardown] Could not stat state file; skipping cleanup for safety:', error); - return; - } - - let parsedState: Partial; - try { - parsedState = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as Partial; - } catch (error) { - console.warn('[teardown] Could not parse state file; skipping cleanup for safety:', error); - return; - } - - const cosignerPid = parsePositivePid(parsedState.cosignerPid, 'cosignerPid'); - const serverPid = parsePositivePid(parsedState.serverPid, 'serverPid'); - const safeTmpDir = skipTempCleanup ? null : resolveSafeTmpDir(parsedState.tmpDir); - - for (const [label, pid] of [['co-signer', cosignerPid], ['server', serverPid]] as const) { - if (!pid) continue; - if (!isProcessRunning(pid)) { - console.warn(`[teardown] ${label} pid ${pid} is not running; skipping SIGTERM.`); - continue; - } - if (!validateProcessIdentity(pid, label)) { - continue; - } - try { - process.kill(pid, 'SIGTERM'); - console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { - console.warn(`[teardown] Could not kill ${label} (pid ${pid}):`, err); - } - } - } - - await new Promise(r => setTimeout(r, 500)); - - if (skipTempCleanup) { - console.warn('[teardown] Skipping temp dir removal because state file is stale.'); - return; - } - - if (!safeTmpDir) { - console.warn('[teardown] Skipping temp dir removal due to invalid tmpDir state.'); - return; - } - - try { - fs.rmSync(safeTmpDir, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', safeTmpDir); - } catch (err) { - console.warn('[teardown] Could not remove temp dir:', err); - } -} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts deleted file mode 100644 index c0a8cd7..0000000 --- a/tests/e2e/helpers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from '@playwright/test'; -import type { Page } from '@playwright/test'; - -export async function loginAs(page: Page, username: string, password: string): Promise { - const usernameField = page - .locator('input[autocomplete="username"], input[id*="user" i], input[name*="user" i], input[placeholder*="user" i]') - .first(); - const passwordField = page.locator('input[type="password"]').first(); - const submitBtn = page.getByRole('button', { name: /login|sign in/i }).first(); - - await usernameField.fill(username); - await passwordField.fill(password); - await submitBtn.click(); - await expect( - page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first(), - 'login failed: expected dashboard tabs after submit' - ).toBeVisible({ timeout: 10_000 }); -} diff --git a/tests/e2e/smoke-test-defaults.json b/tests/e2e/smoke-test-defaults.json deleted file mode 100644 index 2c2419e..0000000 --- a/tests/e2e/smoke-test-defaults.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -} diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts deleted file mode 100644 index d10a25d..0000000 --- a/tests/e2e/specs/01-auth.e2e.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Auth smoke tests – login, logout, session auth, API-key auth, 401 enforcement. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Auth – /api/auth', () => { - test('GET /api/auth/status returns enabled methods', async () => { - await withApi(async (api) => { - const res = await api.get('/api/auth/status'); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(typeof body).toBe('object'); - }); - }); - - test('POST /api/auth/login – valid credentials return sessionId', async () => { - await withApi(async (api) => { - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('sessionId'); - expect(typeof body.sessionId).toBe('string'); - expect(body.sessionId.length).toBeGreaterThan(8); - }); - }); - - test('POST /api/auth/login – wrong password returns 401', async () => { - await withApi(async (api) => { - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: 'WrongPass@1' }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('POST /api/auth/login – unknown user returns 401', async () => { - await withApi(async (api) => { - const res = await api.post('/api/auth/login', { - data: { username: 'nobody', password: 'WrongPass@1' }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/peers – no auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/status – valid session returns 200', async () => { - await withApi(async (api) => { - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/event-log – valid API key (X-API-Key) returns 200', async () => { - test.skip(!apiKey, 'No API key available'); - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey! }, - }); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/event-log – valid API key (Bearer) returns 200', async () => { - test.skip(!apiKey, 'No API key available'); - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { Authorization: `Bearer ${apiKey!}` }, - }); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/peers – invalid API key returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers', { - headers: { 'X-API-Key': 'totally-invalid-key' }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('POST /api/auth/logout – returns 200 and clears session', async () => { - // Log in fresh so we don't burn the shared session - await withApi(async (api) => { - const loginRes = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, - }); - expect(loginRes.status()).toBe(200); - const { sessionId: tempSession } = await loginRes.json(); - - const logoutRes = await api.post('/api/auth/logout', { - headers: { 'X-Session-ID': tempSession }, - }); - expect(logoutRes.status()).toBe(200); - - // The session should now be invalid - const afterRes = await api.get('/api/peers', { - headers: { 'X-Session-ID': tempSession }, - }); - expect(afterRes.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts deleted file mode 100644 index 7ebb7e7..0000000 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Status and peers smoke tests. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -let state: SmokeTestState; -let baseUrl = ''; -let sessionId = ''; -let groupPubkeyHex = ''; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.beforeAll(() => { - state = loadState(); - baseUrl = state.baseUrl; - sessionId = state.sessionId; - groupPubkeyHex = state.groupPubkeyHex; -}); - -test.describe('Status – /api/status', () => { - test('GET /api/status is publicly accessible without auth', async () => { - // /api/status intentionally allows unauthenticated health checks - await withApi(async (api) => { - const res = await api.get('/api/status'); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/status returns 200 with node info', async () => { - await withApi(async (api) => { - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body.serverRunning).toBe(true); - expect(body.nodeActive).toBe(true); - expect(body).toHaveProperty('health'); - expect(body).toHaveProperty('relayCount'); - expect(body).toHaveProperty('timestamp'); - }); - }); - - test('GET /api/status has valid health object', async () => { - await withApi(async (api) => { - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.health).toHaveProperty('isConnected'); - expect(typeof body.health.isConnected).toBe('boolean'); - expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); - }); - }); -}); - -test.describe('Peers – /api/peers', () => { - test('GET /api/peers returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/peers returns peer list', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('peers'); - expect(Array.isArray(body.peers)).toBe(true); - // 2-of-2 keyset: 1 remote peer (self filtered out) - expect(body.peers.length).toBeGreaterThanOrEqual(1); - expect(typeof body.total).toBe('number'); - expect(typeof body.online).toBe('number'); - }); - }); - - test('GET /api/peers/group returns group pubkey', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers/group', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(body.pubkey).toBe(groupPubkeyHex); - expect(typeof body.threshold).toBe('number'); - }); - }); - - test('GET /api/peers/self returns own share pubkey', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers/self', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(typeof body.pubkey).toBe('string'); - }); - }); -}); diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts deleted file mode 100644 index 4657b49..0000000 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * NIP-44 and NIP-04 encrypt/decrypt smoke tests. - * - * We use the group pubkey as the "peer" for ECDH operations – the server - * holds share-0 so it can derive the shared secret with any co-participant. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, groupPubkeyHex } = state; - -const PLAINTEXT = 'Hello from igloo smoke test!'; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -// ─── NIP-44 ────────────────────────────────────────────────────────────────── - -test.describe('NIP-44 – /api/nip44', () => { - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('encrypt returns ciphertext', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - expect(typeof body.result).toBe('string'); - expect(body.result.length).toBeGreaterThan(0); - }); - }); - - test('encrypt then decrypt round-trips plaintext', async () => { - await withApi(async (api) => { - const encRes = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip44/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, - }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - }); - }); - - test('invalid peer_pubkey returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('missing content returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, - }); - expect(res.status()).toBe(400); - }); - }); -}); - -// ─── NIP-04 ────────────────────────────────────────────────────────────────── - -test.describe('NIP-04 – /api/nip04', () => { - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('encrypt returns ciphertext with IV suffix', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - // NIP-04 ciphertext has the form ?iv= - expect(body.result).toMatch(/\?iv=/); - }); - }); - - test('encrypt then decrypt round-trips plaintext', async () => { - await withApi(async (api) => { - const encRes = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip04/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, - }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - }); - }); - - test('invalid peer_pubkey returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('missing content returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, - }); - expect(res.status()).toBe(400); - }); - }); -}); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts deleted file mode 100644 index 36b5ebd..0000000 --- a/tests/e2e/specs/04-sign.e2e.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Signing smoke tests – requires the igloo-cli co-signer launched in global setup. - * - * sign timeout: 5 s (FROSTR_SIGN_TIMEOUT env set in global-setup for smoke runs). - * Test timeout overridden to 30 s to accommodate the signing round-trip. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -type SignEventPayload = { - pubkey: string; - kind: number; - created_at: number; - content: string; - tags: string[][]; -}; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, groupPubkeyHex } = state; - -// Valid 32-byte hex event IDs for signing -const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -const SIGNATURE_REGEX = /^[0-9a-f]{128}$/i; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Sign – /api/sign', () => { - // Explicit per-suite timeout for signing flows; global timeout is also 30_000. - test.setTimeout(30_000); - - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - data: { message: EVENT_ID_A }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('returns 400 for invalid (non-hex) message', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'not-hex' }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('returns 400 for message shorter than 32 bytes', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'deadbeef' }, // only 4 bytes - }); - expect(res.status()).toBe(400); - }); - }); - - test('returns 400 for missing body', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: {}, - }); - expect(res.status()).toBe(400); - }); - }); - - test('signs a 32-byte hex message and returns signature', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: EVENT_ID_A }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('id', EVENT_ID_A); - expect(body).toHaveProperty('signature'); - expect(typeof body.signature).toBe('string'); - // Schnorr signature = 64 bytes = 128 hex chars - expect(body.signature).toMatch(SIGNATURE_REGEX); - }); - }); - - test('signs a full event object and returns signature', async () => { - await withApi(async (api) => { - // Use the group pubkey as the event author pubkey - const event: SignEventPayload = { - pubkey: groupPubkeyHex, - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'igloo smoke test', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { event }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('id'); - expect(body).toHaveProperty('signature'); - expect(body.signature).toMatch(SIGNATURE_REGEX); - }); - }); - - test('signing works with API key auth', async () => { - test.skip(!state.apiKey, 'No API key available'); - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-API-Key': state.apiKey! }, - data: { message: EVENT_ID_B }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.signature).toMatch(SIGNATURE_REGEX); - }); - }); - - test('event with invalid pubkey returns 400', async () => { - await withApi(async (api) => { - const invalidEvent: SignEventPayload = { - pubkey: 'not-64-hex', - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'bad', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { - event: invalidEvent, - }, - }); - expect(res.status()).toBe(400); - }); - }); -}); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts deleted file mode 100644 index 5785ceb..0000000 --- a/tests/e2e/specs/05-admin.e2e.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Admin endpoint smoke tests: - * - API key creation, listing, revocation - * - User listing, whoami - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, adminUsername } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Admin – API keys', () => { - test('GET /api/admin/api-keys returns list', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('apiKeys'); - expect(Array.isArray(body.apiKeys)).toBe(true); - // At minimum the key created in global setup should be here - expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); - }); - }); - - test('POST /api/admin/api-keys creates a new key', async () => { - await withApi(async (api) => { - let createdKeyId: string | number | null = null; - try { - const res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: `temp-test-key-${Date.now()}` }, - }); - expect(res.status()).toBe(201); - const body = await res.json(); - expect(body).toHaveProperty('apiKey'); - expect(body.apiKey).toHaveProperty('token'); - expect(typeof body.apiKey.token).toBe('string'); - expect(body.apiKey.token.length).toBeGreaterThan(20); - expect(body.apiKey).toHaveProperty('id'); - createdKeyId = body.apiKey.id; - } finally { - if (createdKeyId !== null) { - await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, - }).catch(() => null); - } - } - }); - }); - - test('new API key can authenticate', async () => { - await withApi(async (api) => { - let createdKeyId: string | number | null = null; - try { - // Create key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: `auth-test-key-${Date.now()}` }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - createdKeyId = apiKey.id; - - // Use key to hit an auth-protected route. - const authRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(authRes.status()).toBe(200); - } finally { - if (createdKeyId !== null) { - await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, - }).catch(() => null); - } - } - }); - }); - - test('revoked API key returns 401', async () => { - await withApi(async (api) => { - let createdApiKey: { id: string | number; token: string } | null = null; - try { - // Create a fresh key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'revoke-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - createdApiKey = apiKey; - - // Verify it works on an auth-protected endpoint - const beforeRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': createdApiKey.token }, - }); - expect(beforeRes.status()).toBe(200); - - // Revoke it - const revokeRes = await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdApiKey.id, reason: 'smoke-test cleanup' }, - }); - expect(revokeRes.status()).toBe(200); - - // Now the revoked key should be rejected - const afterRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': createdApiKey.token }, - }); - expect(afterRes.status()).toBe(401); - } finally { - if (createdApiKey) { - await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdApiKey.id, reason: 'test cleanup' }, - }).catch(() => null); - } - } - }); - }); - - test('GET /api/admin/api-keys without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/api-keys'); - expect(res.status()).toBe(401); - }); - }); -}); - -test.describe('Admin – Users', () => { - test('GET /api/admin/users returns user list', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/users', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('users'); - expect(Array.isArray(body.users)).toBe(true); - expect(body.users.length).toBeGreaterThanOrEqual(1); - // Our admin user must be in the list - const found = body.users.some((u: { username: string }) => u.username === adminUsername); - expect(found).toBe(true); - }); - }); - - test('GET /api/admin/whoami returns admin identity', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/whoami', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('admin', true); - expect(body).toHaveProperty('userId'); - expect(body.userId).not.toBeNull(); - }); - }); - - test('GET /api/admin/users without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/users'); - expect(res.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts deleted file mode 100644 index 6199100..0000000 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * UI event-log smoke tests. - * This suite seeds its own event-log entries in beforeAll. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Event log – /api/event-log', () => { - test.beforeAll(async () => { - await withApi(async (api) => { - const runUniqueHex = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padStart(64, 'b').slice(0, 64); - const seedRes = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: runUniqueHex }, - }); - if (!seedRes.ok()) { - throw new Error(`Failed to seed event log via /api/sign: ${seedRes.status()} ${await seedRes.text()}`); - } - }); - }); - - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/event-log returns entries array', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('entries'); - expect(Array.isArray(body.entries)).toBe(true); - }); - }); - - test('entries have expected shape', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(Array.isArray(body.entries)).toBe(true); - expect(body.entries.length).toBeGreaterThan(0); - const entry = body.entries[0]; - expect(entry).toHaveProperty('type'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('timestamp'); - }); - }); - - test('pagination params are accepted', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log?limit=5', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.entries.length).toBeLessThanOrEqual(5); - }); - }); - - test('GET /api/event-log/export returns NDJSON', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log/export', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - - // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array - const contentType = res.headers()['content-type'] ?? ''; - expect(contentType).toContain('ndjson'); - - const text = await res.text(); - // Each non-empty line must be valid JSON - const lines = text.trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); - } - }); - }); - - test('export without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log/export'); - expect(res.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts deleted file mode 100644 index 51bc8f4..0000000 --- a/tests/e2e/specs/07-env.e2e.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Credential management smoke tests – /api/env. - * - * GET returns the current credential state. - * POST with invalid credentials returns 400. - * We do NOT update valid credentials here to avoid disrupting co-signer timing. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Env / credentials – /api/env', () => { - test('GET /api/env returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.get('/api/env'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/env with session returns credential metadata', async () => { - await withApi(async (api) => { - const res = await api.get('/api/env', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - // In DB mode the response should indicate that credentials are present - expect(body).toHaveProperty('hasCredentials', true); - }); - }); - - test('POST /api/env – invalid GROUP_CRED returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: 'not-a-valid-bfgroup-credential', - SHARE_CRED: state.shareCredentials[0], - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env – invalid SHARE_CRED returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: 'not-a-valid-bfshare-credential', - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env – invalid relay URL returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: ['not-a-websocket-url'], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env – empty RELAYS returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: [], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, - }); - expect(res.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts deleted file mode 100644 index 92ac82b..0000000 --- a/tests/e2e/specs/08-ui.e2e.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Browser UI smoke tests – uses Playwright's full browser to exercise the SPA. - * - * Prerequisites: `bun run build` must have been run so static/app.js exists. - * The server is already running (started in global-setup). - */ - -import { test, expect } from '@playwright/test'; -import { loginAs } from '../helpers.js'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, adminUsername, adminPassword } = state; - -test.describe('UI – Login page', () => { - test('/ renders the login form when not authenticated', async ({ page }) => { - await page.goto(baseUrl); - // The SPA should show either the login form or onboarding - // Since onboarding is complete, we expect the login form - await expect(page).toHaveURL(new URL('/', baseUrl).toString()); - // Login form has username + password inputs - await expect(page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first()).toBeVisible({ - timeout: 10_000, - }); - await expect(page.locator('input[type="password"]').first()).toBeVisible(); - }); - - test('login form accepts credentials and navigates to dashboard', async ({ page }) => { - await page.goto(baseUrl); - await loginAs(page, adminUsername, adminPassword); - - // After login we should see the main app tabs - await expect(page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first()).toBeVisible({ - timeout: 10_000, - }); - }); -}); - -test.describe('UI – Authenticated app', () => { - // Log in once per test block using page fixtures (each test gets a fresh page) - test.beforeEach(async ({ page }) => { - await page.goto(baseUrl); - await loginAs(page, adminUsername, adminPassword); - }); - - test('Signer tab is visible and shows node status indicator', async ({ page }) => { - // The Signer tab or its content should be visible after login - const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); - await expect(signerTab).toBeVisible({ timeout: 8_000 }); - await signerTab.click(); - await expect(page.locator('body')).toContainText(/server signer:\s*(running|starting|stopped)/i, { timeout: 8_000 }); - await expect(page.locator('body')).toContainText(/\bnode\s+(active|inactive)\b/i, { timeout: 8_000 }); - }); - - test('Back to Configure button navigates to configuration page', async ({ page }) => { - // In signer view, config form copy should not be visible yet. - await expect(page.locator('body')).not.toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); - - const backToConfigure = page.locator('button:has-text("Back to Configure")').first(); - await expect(backToConfigure).toBeVisible({ timeout: 8_000 }); - await backToConfigure.click(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('body')).toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); - }); - - test('API Keys tab is accessible', async ({ page }) => { - const apiKeysTab = page - .locator('[role="tab"]:has-text("API Keys"), [role="tab"]:has-text("Api Keys"), button:has-text("API Keys")') - .first(); - await expect(apiKeysTab).toBeVisible({ timeout: 8_000 }); - await apiKeysTab.click(); - await page.waitForLoadState('networkidle'); - // The tab panel should render without error - await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); - }); - - test('Event Log section is visible on Signer tab and shows no errors', async ({ page }) => { - const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); - await expect(signerTab).toBeVisible({ timeout: 8_000 }); - await signerTab.click(); - - // The Event Log is a collapsible section embedded in the Signer tab (not a top-level tab). - // It renders a div with role="button" and a span containing "Event Log". - const eventLogToggle = page.locator('[role="button"]:has-text("Event Log")').first(); - await expect(eventLogToggle).toBeVisible({ timeout: 8_000 }); - await eventLogToggle.click(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); - }); - - test('Logout button signs out and returns to login', async ({ page }) => { - // Find and click a logout button - const logoutBtn = page - .locator('button:has-text("Logout"), button:has-text("Sign out"), a:has-text("Logout"), [aria-label*="logout" i]') - .first(); - await expect(logoutBtn).toBeVisible({ timeout: 8_000 }); - await logoutBtn.click(); - await page.waitForLoadState('networkidle'); - - // Should be back at the login form - await expect(page.locator('input[type="password"]').first()).toBeVisible({ timeout: 8_000 }); - }); -}); - -test.describe('UI – Onboarding already completed', () => { - test('/ does not show onboarding when DB is initialised', async ({ page }) => { - await page.goto(baseUrl); - // Specific onboarding copy should not appear once DB is initialized. - await expect(page.locator('body')).not.toContainText(/enter the admin secret to begin setting up your igloo server/i, { timeout: 6_000 }); - await expect(page.locator('body')).not.toContainText(/create your admin account to secure your igloo server/i, { timeout: 6_000 }); - }); -}); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts deleted file mode 100644 index 8e52078..0000000 --- a/tests/e2e/state.ts +++ /dev/null @@ -1,91 +0,0 @@ -import fs from 'fs'; -import { z } from 'zod'; - -export interface SmokeTestState { - port: number; - baseUrl: string; - tmpDir: string; - serverPid: number; - cosignerPid: number; - sessionId: string; - apiKey: string | null; - apiKeyId: string | null; - groupCredential: string; - shareCredentials: string[]; - groupPubkeyHex: string; // x-only (no 02/03 prefix), used for NIP-44/NIP-04 - adminUsername: string; - adminPassword: string; - adminSecret: string; -} - -/** - * Zod schema for SmokeTestState persisted by global-setup. - * Validates shape and types before returning from loadState. - */ -const SMOKE_TEST_STATE_SCHEMA = z.object({ - port: z.number().int().positive(), - baseUrl: z.string().min(1, 'baseUrl must be non-empty'), - tmpDir: z.string(), - serverPid: z.number().int().nonnegative(), - cosignerPid: z.number().int().nonnegative(), - sessionId: z.string().min(1, 'sessionId must be non-empty'), - apiKey: z.string().nullable(), - apiKeyId: z.string().nullable(), - groupCredential: z.string().min(1, 'groupCredential must be non-empty'), - shareCredentials: z - .array(z.string().min(1, 'share credential must be non-empty')) - .nonempty('shareCredentials must contain at least one credential'), - groupPubkeyHex: z - .string() - .regex(/^[0-9a-fA-F]{64}$/, 'groupPubkeyHex must be exactly 64 hex characters'), - adminUsername: z.string().min(1, 'adminUsername must be non-empty'), - adminPassword: z.string().min(1, 'adminPassword must be non-empty'), - adminSecret: z.string().min(1, 'adminSecret must be non-empty'), -}); - -const STUB: SmokeTestState = { - port: 18002, - baseUrl: 'http://localhost:18002', - tmpDir: '', - serverPid: 0, - cosignerPid: 0, - sessionId: '', - apiKey: null, - apiKeyId: null, - groupCredential: '', - shareCredentials: [], - groupPubkeyHex: '', - adminUsername: '', - adminPassword: '', - adminSecret: '', -}; - -/** - * Load shared test state written by global-setup. - * During test discovery (--list) or if SMOKE_STATE_FILE is not yet set, returns - * a harmless stub so that module-level const initialisations succeed. - * The real values are always present when tests actually execute. - */ -export function loadState(): SmokeTestState { - const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile) { - return { - ...STUB, - shareCredentials: [...STUB.shareCredentials], - }; - } - try { - const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - const result = SMOKE_TEST_STATE_SCHEMA.safeParse(parsed); - if (!result.success) { - const issues = result.error.issues - .map(i => `${i.path.join('.')}: ${i.message}`) - .join('; '); - throw new Error(`validation failed: ${issues}`); - } - return result.data; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid smoke test state in ${stateFile}: ${detail}`); - } -} diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index f5e8667..85a87a5 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; function normalizeOptionalEnv(value: unknown): string | undefined { @@ -10,33 +7,10 @@ function normalizeOptionalEnv(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function loadFixtureTestKeysetSecret(): string | undefined { - const fixturePath = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'e2e', - 'smoke-test-defaults.json' - ); - if (!fs.existsSync(fixturePath)) return undefined; - try { - const raw: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - if (typeof raw !== 'object' || raw === null) return undefined; - const testNsecHex = (raw as Record).testNsecHex; - return typeof testNsecHex === 'string' && testNsecHex.trim().length > 0 ? testNsecHex : undefined; - } catch { - return undefined; - } -} - const TEST_KEYSET_SECRET = normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? - normalizeOptionalEnv(loadFixtureTestKeysetSecret()); -if (!TEST_KEYSET_SECRET) { - throw new Error( - 'TEST_KEYSET_SECRET (or TEST_NSEC_HEX) must be set, or tests/e2e/smoke-test-defaults.json must provide testNsecHex.' - ); -} + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { diff --git a/tests/routes/status-env.spec.ts b/tests/routes/status-env.test.ts similarity index 100% rename from tests/routes/status-env.spec.ts rename to tests/routes/status-env.test.ts From f78ab77eace9b75d5317fd3b2ddd8c2950a1b350 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 17:12:39 -0600 Subject: [PATCH 23/32] fix: bump minimatch override past audit vulnerability --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index f7af0d5..3e8b25b 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "fast-xml-parser": "^5.3.6", "glob": "^10.5.0", "js-yaml": "^4.1.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.3", "undici": "^6.23.0", }, "packages": { @@ -545,7 +545,7 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], diff --git a/package.json b/package.json index 9fa9da0..53ce8af 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ }, "overrides": { "glob": "^10.5.0", - "minimatch": "^10.2.1", + "minimatch": "^10.2.3", "js-yaml": "^4.1.1", "undici": "^6.23.0", "ajv": "^8.18.0", From 27a8e24562da9f407bea1575b002e1d87c53efea Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 17:16:48 -0600 Subject: [PATCH 24/32] fix: apply pending auth env hardening updates --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 38 ++----- .github/workflows/umbrel-dev.yml | 9 +- Dockerfile | 4 +- docs/DEPLOY.md | 4 +- docs/openapi/README.md | 5 +- docs/openapi/openapi.json | 103 +++++++++++++----- docs/openapi/openapi.yaml | 53 ++++++--- env.example | 6 +- frontend/App.tsx | 36 +++--- frontend/components/ApiKeys.tsx | 9 +- frontend/components/Configure.tsx | 68 ++++++++++-- frontend/components/Login.tsx | 28 ++++- frontend/components/NIP46.tsx | 46 +++++--- frontend/components/Onboarding.tsx | 41 ++++--- frontend/components/Recover.tsx | 10 +- frontend/components/Signer.tsx | 38 +++++-- frontend/components/nip46/Permissions.tsx | 4 +- frontend/components/nip46/RelaySettings.tsx | 4 +- frontend/components/ui/event-log.tsx | 8 +- frontend/components/ui/icon-button.tsx | 4 +- frontend/components/ui/peer-list.tsx | 14 +-- frontend/components/ui/tooltip.tsx | 5 +- scripts/fetch-swagger-ui.mjs | 2 +- scripts/patch-zod-compat.mjs | 2 +- src/class/relay.ts | 6 +- src/config/crypto.ts | 5 +- src/const.ts | 3 +- src/db/database.ts | 15 ++- .../20250916_0004_audit_nip46_data_sizes.sql | 28 +++-- src/db/nip46.ts | 59 ++++++---- src/nip46/service.ts | 20 ++-- src/node/manager.ts | 68 ++++++++---- src/routes/admin.ts | 24 ++-- src/routes/auth.ts | 87 ++++++++------- src/routes/env.ts | 36 +++++- src/routes/index.ts | 58 ++++++---- src/routes/nip46.ts | 23 +++- src/routes/onboarding.ts | 6 +- src/routes/peers.ts | 48 ++++---- src/routes/sign.ts | 4 +- src/routes/status.ts | 2 +- src/routes/update.ts | 5 +- src/routes/user.ts | 23 +++- src/utils/rate-limiter.ts | 21 ++-- tests/routes/admin.whoami.session.spec.ts | 9 +- tests/routes/auth.rehydrate.spec.ts | 5 +- tests/routes/body-limit.spec.ts | 4 +- tests/routes/onboarding.spec.ts | 16 ++- tests/routes/status-env.test.ts | 1 + 50 files changed, 740 insertions(+), 381 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec46391..a40223b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ master, dev ] + branches: [ main, dev ] pull_request: - branches: [ master, dev ] + branches: [ main, dev ] jobs: test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8163bb7..4717255 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: jobs: release: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' + if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' permissions: contents: write pull-requests: write @@ -126,28 +126,27 @@ jobs: --exclude=.git \ --exclude=release \ --exclude=frontend \ - src static package.json bun.lock tsconfig.json dockerfile compose.yml README.md LICENSE + src static package.json bun.lock tsconfig.json Dockerfile compose.yml README.md LICENSE - name: Create release tag run: | # Create git tag for release (works with branch protection) - # Note: Version changes are not committed back to master due to branch protection + # Note: Version changes are not committed back to main due to branch protection # The release archives will contain the correct versions git tag ${{ steps.new_version.outputs.new_version }} git push origin ${{ steps.new_version.outputs.new_version }} - - name: Create GitHub Release - uses: actions/create-release@v1 - id: create_release + - name: Create GitHub release and upload assets + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ steps.new_version.outputs.new_version }} - release_name: Release ${{ steps.new_version.outputs.new_version }} + name: Release ${{ steps.new_version.outputs.new_version }} body: | ## Changes in ${{ steps.new_version.outputs.new_version }} - See [CHANGELOG.md](https://github.com/FROSTR-ORG/igloo-server/blob/master/CHANGELOG.md) for full details. + See [CHANGELOG.md](https://github.com/FROSTR-ORG/igloo-server/blob/main/CHANGELOG.md) for full details. ### Installation @@ -166,26 +165,9 @@ jobs: ``` draft: false prerelease: false - - - name: Upload source archive - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release/igloo-server-${{ steps.new_version.outputs.version_number }}-src.tar.gz - asset_name: igloo-server-${{ steps.new_version.outputs.version_number }}-src.tar.gz - asset_content_type: application/gzip - - - name: Upload binary archive - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release/igloo-server-${{ steps.new_version.outputs.version_number }}.tar.gz - asset_name: igloo-server-${{ steps.new_version.outputs.version_number }}.tar.gz - asset_content_type: application/gzip + files: | + ./release/igloo-server-${{ steps.new_version.outputs.version_number }}-src.tar.gz + ./release/igloo-server-${{ steps.new_version.outputs.version_number }}.tar.gz docker: runs-on: ubuntu-latest diff --git a/.github/workflows/umbrel-dev.yml b/.github/workflows/umbrel-dev.yml index 706ec5a..669a49b 100644 --- a/.github/workflows/umbrel-dev.yml +++ b/.github/workflows/umbrel-dev.yml @@ -3,7 +3,7 @@ name: Umbrel Dev Image on: push: branches: - - master + - main - dev workflow_dispatch: @@ -39,6 +39,12 @@ jobs: - name: Smoke test Umbrel image run: | + set -e + cleanup() { + docker rm -f umbrel-dev-test >/dev/null 2>&1 || true + } + trap cleanup EXIT + docker run -d --name umbrel-dev-test -p 8003:8002 \ -e ADMIN_SECRET=ci-admin-secret \ -e ALLOWED_ORIGINS=http://localhost:8003 \ @@ -46,4 +52,3 @@ jobs: igloo-server-umbrel:dev-ci sleep 12 curl -f http://localhost:8003/api/status - docker rm -f umbrel-dev-test diff --git a/Dockerfile b/Dockerfile index 0052609..f918c40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for smaller production image -FROM oven/bun:1.3.3 AS build +FROM oven/bun:1.3.10 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # --- Production stage --- -FROM oven/bun:1.3.3 AS production +FROM oven/bun:1.3.10 AS production WORKDIR /app diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 00acb3f..ba2391c 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -62,7 +62,9 @@ Note: `env_file: .env` injects values only at container start. If you also bind- 4) Firewall (UFW): ```bash -sudo ufw allow 80 443 22 +sudo ufw allow 22 +sudo ufw allow 80 +sudo ufw allow 443 sudo ufw allow 8002 # only if accessing without reverse proxy sudo ufw enable ``` diff --git a/docs/openapi/README.md b/docs/openapi/README.md index b9fecba..c6069d5 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -39,8 +39,9 @@ Mode and auth nuance: ### Authentication for API Documentation -- **Development**: No authentication required for easy testing -- **Production**: Authentication required for security (enforced by the server) +Authentication behavior is configuration-dependent: +- **Development**: authentication is commonly disabled by default for local testing, but controlled by environment variables (see `AUTH_ENABLED` in `env.example`) +- **Production**: authentication should be enabled and enforced by server configuration To authenticate in Swagger UI: 1. Use the "Authorize" button in Swagger UI diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 2ac19fa..2653b2c 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -442,6 +442,7 @@ "tags": [ "Authentication" ], + "security": [], "requestBody": { "required": true, "content": { @@ -524,6 +525,7 @@ "tags": [ "Authentication" ], + "security": [], "responses": { "200": { "description": "Logout successful", @@ -1384,15 +1386,36 @@ "$ref": "#/components/schemas/StoredShare" } }, - "example": [ - { - "shareCredential": "bfshare1qqsqp...", - "groupCredential": "bfgroup1qqsqp...", - "savedAt": "2025-01-20T12:00:00.000Z", - "id": "env-stored-share", - "source": "environment" + "examples": { + "metadataOnly": { + "summary": "Default response with credential presence flags only", + "value": [ + { + "hasShareCredential": true, + "hasGroupCredential": true, + "isValid": true, + "savedAt": "2025-01-20T12:00:00.000Z", + "id": "env-stored-share", + "source": "environment" + } + ] + }, + "includesRaw": { + "summary": "Debug response with raw credentials included", + "value": [ + { + "hasShareCredential": true, + "hasGroupCredential": true, + "isValid": true, + "shareCredential": "bfshare1qqsqp...", + "groupCredential": "bfgroup1qqsqp...", + "savedAt": "2025-01-20T12:00:00.000Z", + "id": "env-stored-share", + "source": "environment" + } + ] } - ] + } } } }, @@ -3000,13 +3023,13 @@ "methods": { "type": "array", "items": { - "type": "string", - "enum": [ - "api-key", - "basic-auth", - "session" - ] - }, + "type": "string", + "enum": [ + "api-key", + "basic-auth", + "session" + ] + }, "description": "Available authentication methods" }, "rateLimiting": { @@ -3210,16 +3233,37 @@ "StoredShare": { "type": "object", "properties": { + "hasShareCredential": { + "type": "boolean", + "description": "Whether a share credential is currently stored" + }, + "hasGroupCredential": { + "type": "boolean", + "description": "Whether a group credential is currently stored" + }, + "isValid": { + "type": "boolean", + "description": "Whether both credentials are present and usable together" + }, "shareCredential": { - "type": "string", - "description": "The share credential" + "type": [ + "string", + "null" + ], + "description": "Optional raw share credential (only returned in explicit debug mode)" }, "groupCredential": { - "type": "string", - "description": "The group credential" + "type": [ + "string", + "null" + ], + "description": "Optional raw group credential (only returned in explicit debug mode)" }, "savedAt": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", "description": "When the share was saved" }, @@ -3233,8 +3277,9 @@ } }, "required": [ - "shareCredential", - "groupCredential", + "hasShareCredential", + "hasGroupCredential", + "isValid", "savedAt", "id", "source" @@ -4064,12 +4109,12 @@ ] }, "example": { - "error": "Authentication required", - "authMethods": [ - "api-key", - "basic-auth", - "session" - ] + "error": "Authentication required", + "authMethods": [ + "api-key", + "basic-auth", + "session" + ] } } } @@ -4179,4 +4224,4 @@ } } } -} +} \ No newline at end of file diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index b654aad..c2eea82 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -342,6 +342,7 @@ paths: description: Login with username/password or API key to get a session tags: - Authentication + security: [] requestBody: required: true content: @@ -394,6 +395,7 @@ paths: description: Clear the current session tags: - Authentication + security: [] responses: '200': description: Logout successful @@ -947,12 +949,27 @@ paths: type: array items: $ref: '#/components/schemas/StoredShare' - example: - - shareCredential: "bfshare1qqsqp..." - groupCredential: "bfgroup1qqsqp..." - savedAt: "2025-01-20T12:00:00.000Z" - id: "env-stored-share" - source: "environment" + examples: + metadataOnly: + summary: Default response with credential presence flags only + value: + - hasShareCredential: true + hasGroupCredential: true + isValid: true + savedAt: "2025-01-20T12:00:00.000Z" + id: "env-stored-share" + source: "environment" + includesRaw: + summary: Debug response with raw credentials included + value: + - hasShareCredential: true + hasGroupCredential: true + isValid: true + shareCredential: "bfshare1qqsqp..." + groupCredential: "bfgroup1qqsqp..." + savedAt: "2025-01-20T12:00:00.000Z" + id: "env-stored-share" + source: "environment" '401': $ref: '#/components/responses/Unauthorized' '500': @@ -2100,14 +2117,23 @@ components: StoredShare: type: object properties: + hasShareCredential: + type: boolean + description: Whether a share credential is currently stored + hasGroupCredential: + type: boolean + description: Whether a group credential is currently stored + isValid: + type: boolean + description: Whether both credentials are present and usable together shareCredential: - type: string - description: The share credential + type: ["string", "null"] + description: Optional raw share credential (only returned in explicit debug mode) groupCredential: - type: string - description: The group credential + type: ["string", "null"] + description: Optional raw group credential (only returned in explicit debug mode) savedAt: - type: string + type: ["string", "null"] format: date-time description: When the share was saved id: @@ -2117,8 +2143,9 @@ components: type: string description: Source of the share (e.g., "environment") required: - - shareCredential - - groupCredential + - hasShareCredential + - hasGroupCredential + - isValid - savedAt - id - source diff --git a/env.example b/env.example index 03a0b80..9eff174 100644 --- a/env.example +++ b/env.example @@ -252,7 +252,7 @@ NODE_ENV=development # PERSONAL (Home server - medium security) # AUTH_ENABLED=true # API_KEY=personal-server-key-2024 -# SESSION_SECRET=personal-session-secret-32chars +# SESSION_SECRET=4f7c1e9a2b3d4c5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6 # SESSION_TIMEOUT=7200 # RATE_LIMIT_MAX=50 @@ -261,7 +261,7 @@ NODE_ENV=development # BASIC_AUTH_USER=teamadmin # BASIC_AUTH_PASS=SecureTeamPassword123! # API_KEY=team-automation-key-64chars -# SESSION_SECRET=team-session-secret-32plus-chars +# SESSION_SECRET=9a8b7c6d5e4f3021a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0 # SESSION_TIMEOUT=3600 # RATE_LIMIT_MAX=100 @@ -271,7 +271,7 @@ NODE_ENV=development # BASIC_AUTH_USER=prodadmin # BASIC_AUTH_PASS=VerySecurePassword456! # API_KEY=prod-api-key-with-64-random-chars-abcdef123456789 -# SESSION_SECRET=prod-session-secret-256-bits-of-entropy-required-for-production +# SESSION_SECRET=0123456789abcdef0123456789abcdefabcdef0123456789abcdef0123456789 # SESSION_TIMEOUT=1800 # RATE_LIMIT_ENABLED=true # RATE_LIMIT_WINDOW=300 diff --git a/frontend/App.tsx b/frontend/App.tsx index 8874f96..0ad15c7 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from "react" +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react" import Configure from "./components/Configure" import Signer from "./components/Signer" import Recover from "./components/Recover" @@ -87,18 +87,6 @@ const App: React.FC = () => { }; }, []); - // Global handler for authentication/credentials expiry from child components - useEffect(() => { - const onAuthExpired = () => { - // If already unauthenticated, ignore; otherwise trigger logout to show login screen - if (authState.isAuthenticated) { - handleLogout().catch(console.error); - } - }; - window.addEventListener('authExpired', onAuthExpired as EventListener); - return () => window.removeEventListener('authExpired', onAuthExpired as EventListener); - }, [authState.isAuthenticated]); - const initializeApp = async () => { try { // Check onboarding status first with retry logic @@ -333,7 +321,7 @@ const App: React.FC = () => { } }; - const getAuthHeaders = (): Record => { + const getAuthHeaders = useCallback((): Record => { const headers: Record = {}; // Try session-based auth first @@ -351,13 +339,13 @@ const App: React.FC = () => { } return headers; - }; + }, [authState.sessionId, authState.apiKey, authState.basicAuth]); // Memoize auth headers to prevent unnecessary re-renders in child components // Only recreate when authentication state changes const memoizedAuthHeaders = useMemo(() => { return getAuthHeaders(); - }, [authState.sessionId, authState.apiKey, authState.basicAuth]); + }, [getAuthHeaders]); const handleOnboardingComplete = () => { // After onboarding, reset state to show login @@ -401,7 +389,7 @@ const App: React.FC = () => { } }; - const handleLogout = async () => { + const handleLogout = useCallback(async () => { try { // Stop signer first await signerRef.current?.stopSigner().catch(console.error); @@ -422,7 +410,19 @@ const App: React.FC = () => { }); setSignerData(null); } - }; + }, [getAuthHeaders]); + + // Global handler for authentication/credentials expiry from child components + useEffect(() => { + const onAuthExpired = () => { + // If already unauthenticated, ignore; otherwise trigger logout to show login screen + if (authState.isAuthenticated) { + handleLogout().catch(console.error); + } + }; + window.addEventListener('authExpired', onAuthExpired as EventListener); + return () => window.removeEventListener('authExpired', onAuthExpired as EventListener); + }, [authState.isAuthenticated, handleLogout]); const checkAdmin = async (headers?: Record) => { try { diff --git a/frontend/components/ApiKeys.tsx b/frontend/components/ApiKeys.tsx index 8ac7905..7839f1a 100644 --- a/frontend/components/ApiKeys.tsx +++ b/frontend/components/ApiKeys.tsx @@ -53,6 +53,7 @@ const ApiKeys: React.FC = ({ authHeaders = {}, headlessMode = fals // Track copy timeout to avoid leaks if component unmounts before it fires const copyTimeoutRef = useRef | null>(null) + const initialAdminLoadRef = useRef(false) const combinedHeaders = useCallback( (contentType = true) => { @@ -223,9 +224,13 @@ const ApiKeys: React.FC = ({ authHeaders = {}, headlessMode = fals const revokedKeys = useMemo(() => keys.filter(key => key.revokedAt), [keys]) useEffect(() => { - if (isAdminUser) { - loadKeys().catch(err => console.error('Failed to load keys:', err)) + if (!isAdminUser) { + initialAdminLoadRef.current = false + return } + if (initialAdminLoadRef.current) return + initialAdminLoadRef.current = true + loadKeys().catch(err => console.error('Failed to load keys:', err)) }, [isAdminUser, loadKeys]) if (headlessMode) { diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index df2ba17..73effdd 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react" +import React, { useState, useEffect, useRef, useId, useCallback } from "react" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Tooltip } from "./ui/tooltip" @@ -80,6 +80,10 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav const [isLoadingConfig, setIsLoadingConfig] = useState(true); const [advancedError, setAdvancedError] = useState(undefined); const loadAdvancedSettingsRef = useRef(null); + const clearConfirmButtonRef = useRef(null); + const clearCancelButtonRef = useRef(null); + const clearTriggerButtonRef = useRef(null); + const clearDialogTitleId = useId(); /** * Convert an environment value of unknown type to a string suitable for input fields. @@ -106,7 +110,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } // Function to load advanced settings from env - const loadAdvancedSettings = async () => { + const loadAdvancedSettings = useCallback(async (headlessMode: boolean) => { // Load advanced settings in both headless and database modes if (loadAdvancedSettingsRef.current) { try { loadAdvancedSettingsRef.current.abort() } catch {} @@ -144,7 +148,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav // Only include RELAYS in headless mode (server-wide configuration) // In database mode, relays are managed per-user through the Signer component - if (isHeadlessMode) { + if (headlessMode) { newSettings.RELAYS = coerceEnvValueToString(envVars.RELAYS, '["wss://relay.primal.net"]'); } setAdvancedSettings(newSettings); @@ -162,7 +166,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } finally { setIsLoadingAdvanced(false); } - }; + }, [authHeaders]); const handleRevealAdminSecret = async () => { if (!canRevealAdminSecret) return; @@ -261,7 +265,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } // Load advanced settings in both modes - await loadAdvancedSettings(); + await loadAdvancedSettings(headlessMode); // Store existing relays (if any) if (savedRelays && Array.isArray(savedRelays) && savedRelays.length > 0) { @@ -305,15 +309,15 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } }; loadExistingData(); - }, []); + }, [authHeaders, loadAdvancedSettings]); // Reload advanced settings when window regains focus or when showAdvanced changes // This ensures relay changes from Signer.tsx are reflected here useEffect(() => { if (!showAdvanced) return; - const handleFocus = () => { loadAdvancedSettings() }; - loadAdvancedSettings(); + const handleFocus = () => { void loadAdvancedSettings(isHeadlessMode) }; + void loadAdvancedSettings(isHeadlessMode); window.addEventListener('focus', handleFocus); return () => { window.removeEventListener('focus', handleFocus); @@ -322,7 +326,41 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav loadAdvancedSettingsRef.current = null } }; - }, [showAdvanced]); + }, [showAdvanced, isHeadlessMode, loadAdvancedSettings]); + + useEffect(() => { + if (!showClearConfirm) return; + const previousFocus = (document.activeElement as HTMLElement | null) ?? clearTriggerButtonRef.current; + const focusPrimary = () => clearConfirmButtonRef.current?.focus(); + const raf = window.requestAnimationFrame(focusPrimary); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setShowClearConfirm(false); + return; + } + if (event.key !== 'Tab') return; + const focusables = [clearCancelButtonRef.current, clearConfirmButtonRef.current].filter(Boolean) as HTMLElement[]; + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.cancelAnimationFrame(raf); + window.removeEventListener('keydown', handleKeyDown); + (clearTriggerButtonRef.current ?? previousFocus)?.focus(); + }; + }, [showClearConfirm]); const handleNameChange = (value: string) => { setKeysetName(value); @@ -712,6 +750,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav
{hasExistingCredentials && ( diff --git a/package.json b/package.json index 80f107e..d0704c8 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,11 @@ "typescript": "^5.7.3" }, "overrides": { - "glob": "^10.5.0", - "minimatch": "^10.2.3", - "js-yaml": "^4.1.1", - "undici": "^6.23.0", - "ajv": "^8.18.0", - "fast-xml-parser": "^5.3.8" + "glob": "10.5.0", + "minimatch": "10.2.4", + "js-yaml": "4.1.1", + "undici": "6.23.0", + "ajv": "8.18.0", + "fast-xml-parser": "5.4.1" } } diff --git a/packages/umbrel/igloo/Dockerfile b/packages/umbrel/igloo/Dockerfile index 9f3659d..fb6bd0e 100644 --- a/packages/umbrel/igloo/Dockerfile +++ b/packages/umbrel/igloo/Dockerfile @@ -1,5 +1,5 @@ # Umbrel-specific igloo-server image with non-root runtime user -FROM oven/bun:1.1.30 AS build +FROM oven/bun:1.3.10 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # Production stage with non-root user that matches Umbrel defaults -FROM oven/bun:1.1.30 AS production +FROM oven/bun:1.3.10 AS production ARG IGLOO_USER=igloo ARG IGLOO_UID=1000 diff --git a/src/const.ts b/src/const.ts index edc6387..ed2e69a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -23,8 +23,11 @@ export const RELAYS: string[] = (() => { })(); export const HOST_NAME = process.env['HOST_NAME'] ?? 'localhost' -const parsedHostPort = parseInt(process.env['HOST_PORT'] ?? '8002', 10) -export const HOST_PORT = Number.isNaN(parsedHostPort) ? 8002 : parsedHostPort +const rawHostPort = process.env['HOST_PORT']?.trim() +const parsedHostPort = rawHostPort && /^\d+$/.test(rawHostPort) ? Number(rawHostPort) : NaN +export const HOST_PORT = Number.isInteger(parsedHostPort) && parsedHostPort >= 1 && parsedHostPort <= 65535 + ? parsedHostPort + : 8002 // Raw credential strings for igloo-core functions - treat empty/whitespace as absent export const GROUP_CRED = (() => { diff --git a/src/db/database.ts b/src/db/database.ts index 1071c2e..5c2e8b5 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -11,11 +11,7 @@ const defaultDbDir = path.join(process.cwd(), 'data'); const envPath = process.env.DB_PATH; const isEnvPathFile = !!envPath && ( envPath.endsWith('.db') || - ( - path.extname(envPath) !== '' && - !envPath.endsWith(path.sep) && - path.basename(envPath).includes('.') - ) + (path.extname(envPath) !== '' && !envPath.endsWith(path.sep)) ); const DB_DIR = isEnvPathFile ? path.dirname(envPath as string) : (envPath || defaultDbDir); const DB_FILE = isEnvPathFile ? (envPath as string) : path.join(DB_DIR, 'igloo.db'); diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 987516c..2376e55 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -1,5 +1,5 @@ import path from 'path' -import { existsSync, readdirSync, readFileSync } from 'fs' +import { existsSync, readdirSync, readFileSync, realpathSync } from 'fs' import db from './database.js' function ensureMigrationsTable() { @@ -26,13 +26,20 @@ export function runMigrations(migrationsDirRel = 'src/db/migrations', opts?: { s path.isAbsolute(migrationsDirRel) ? migrationsDirRel : path.join(process.cwd(), migrationsDirRel) ) + if (!existsSync(dir)) return [] + // Security: Ensure migrations directory is within project boundaries const projectRoot = path.resolve(process.cwd()) - if (!dir.startsWith(projectRoot + path.sep)) { - throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) + try { + const resolvedRealDir = realpathSync(dir) + const resolvedProjectRoot = realpathSync(projectRoot) + if (resolvedRealDir !== resolvedProjectRoot && !resolvedRealDir.startsWith(resolvedProjectRoot + path.sep)) { + throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Security: Failed to validate migration directory path: ${detail}`) } - - if (!existsSync(dir)) return [] const files = readdirSync(dir) .filter(f => f.toLowerCase().endsWith('.sql')) .sort() diff --git a/src/node/manager.ts b/src/node/manager.ts index 32c1397..b545e50 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -2185,15 +2185,29 @@ export async function createNodeWithCredentials( const result = await withSimplePoolSubscribeManyLock(async () => { let restoreSubscribeMany: (() => void) | null = null; try { - const poolProto: any = SimplePool?.prototype; - const originalSubscribeMany = poolProto?.subscribeMany; + const poolProtoUnknown: unknown = SimplePool?.prototype; + const poolProto = poolProtoUnknown && typeof poolProtoUnknown === 'object' + ? poolProtoUnknown as { subscribeMany?: unknown } + : null; + const originalSubscribeMany: unknown = poolProto?.subscribeMany; if (poolProto && typeof originalSubscribeMany === 'function') { + const originalSubscribeManyFn = originalSubscribeMany as ( + this: unknown, + relays: unknown, + filters: unknown, + params: unknown + ) => unknown; // Scope filter normalization to this node-creation attempt only. - poolProto.subscribeMany = function normalizedSubscribeMany(this: any, relays: any, filters: any, params: any) { + poolProto.subscribeMany = function normalizedSubscribeMany( + this: unknown, + relays: unknown, + filters: unknown, + params: unknown + ) { const normalizedFilters = Array.isArray(filters) && filters.length === 1 && filters[0] && typeof filters[0] === 'object' && !Array.isArray(filters[0]) ? filters[0] : filters; - return originalSubscribeMany.call(this, relays, normalizedFilters, params); + return originalSubscribeManyFn.call(this, relays, normalizedFilters, params); }; restoreSubscribeMany = () => { poolProto.subscribeMany = originalSubscribeMany; diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 50e951b..29c447c 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -24,6 +24,15 @@ interface RevokeApiKeyRequest { reason?: unknown; } +const KNOWN_ADMIN_PATHS = new Set([ + '/api/admin/whoami', + '/api/admin/users', + '/api/admin/users/delete', + '/api/admin/api-keys', + '/api/admin/api-keys/revoke', + '/api/admin/status', +]); + /** * Convert various input types into a normalized positive integer (number or bigint). * Accepts number, numeric string (e.g. "1", "42"), and bigint (e.g. 1n). @@ -453,15 +462,7 @@ export async function handleAdminRoute( break; } - const knownAdminPaths = new Set([ - '/api/admin/whoami', - '/api/admin/users', - '/api/admin/users/delete', - '/api/admin/api-keys', - '/api/admin/api-keys/revoke', - '/api/admin/status', - ]); - const isKnownPath = knownAdminPaths.has(url.pathname); + const isKnownPath = KNOWN_ADMIN_PATHS.has(url.pathname); return Response.json( { error: isKnownPath ? 'Method not allowed' : 'Not found' }, { status: isKnownPath ? 405 : 404, headers } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index cb801fe..5c6d739 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1211,7 +1211,12 @@ function getAvailableAuthMethods(): string[] { } // Status endpoint for authentication info -export function getAuthStatus(): object { +export function getAuthStatus(): { + enabled: boolean; + methods: string[]; + rateLimiting: boolean; + sessionTimeout: number; +} { return { enabled: AUTH_CONFIG.ENABLED, methods: getAvailableAuthMethods(), diff --git a/src/routes/index.ts b/src/routes/index.ts index 264a304..3facd24 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -132,9 +132,10 @@ export async function handleRequest( if (AUTH_CONFIG.ENABLED && process.env.NODE_ENV === 'production') { const authResult = await authenticate(req); if (!authResult.authenticated) { + const authStatus = getAuthStatus(); return Response.json({ error: 'Authentication required for API documentation in production', - authMethods: getAuthStatus() + authMethods: authStatus.methods }, { status: 401, headers @@ -228,9 +229,10 @@ export async function handleRequest( if (!authResult.authenticated) { // Don't set WWW-Authenticate header to avoid browser's native auth dialog // The frontend will handle authentication through its own UI + const authStatus = getAuthStatus(); return Response.json({ error: authResult.error || 'Authentication required', - authMethods: getAuthStatus() + authMethods: authStatus.methods }, { status: 401, headers diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index c9ab656..43c586d 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -88,8 +88,13 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon // Separate bucket for crypto operations const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const resetAt = typeof rate.resetAt === 'number' && Number.isFinite(rate.resetAt) ? rate.resetAt : null + const retryAfterFromReset = resetAt !== null + ? Math.max(0, Math.ceil((resetAt - Date.now()) / 1000)) + : null const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10) - const retryAfter = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900 + const retryAfterFallback = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900 + const retryAfter = retryAfterFromReset !== null ? retryAfterFromReset : retryAfterFallback return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, 'Retry-After': retryAfter.toString() } diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index 9c0e07c..885f9bc 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -38,9 +38,11 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon // Use a dedicated bucket separate from signing traffic. const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '', 10); + const retryAfterSeconds = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900; return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, - headers: { ...headers, 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() } + headers: { ...headers, 'Retry-After': Math.ceil(retryAfterSeconds).toString() } }); } diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index 85632f8..ed4879a 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -268,10 +268,10 @@ const UNIFORM_AUTH_ERROR = { error: 'Authentication failed' }; // - Uppercase letter // - Lowercase letter // - Digit -// - Special character (must include at least one of @$!%*?&) +// - Special character (any non-alphanumeric, excluding whitespace) // Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH. // Whitespace is allowed and preserved by policy. -const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).*$/; +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).*$/; /** * Validates the admin secret in a timing-safe manner diff --git a/src/routes/peers.ts b/src/routes/peers.ts index ae63a02..a84e050 100644 --- a/src/routes/peers.ts +++ b/src/routes/peers.ts @@ -107,15 +107,16 @@ async function persistUserPeerPolicies( })); const sanitizedPolicies = sanitizePeerPolicyEntries(rawPolicies) as StoredPeerPolicy[]; const hasPolicies = sanitizedPolicies.length > 0; + const fallbackPolicies = hasPolicies ? sanitizedPolicies : null; if (HEADLESS) { - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + await saveFallbackPeerPolicies(fallbackPolicies); return; } const userId = resolveDatabaseUserId(auth); if (userId === null) { - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + await saveFallbackPeerPolicies(fallbackPolicies); return; } @@ -123,21 +124,24 @@ async function persistUserPeerPolicies( const { updateUserPeerPolicies } = await import('../db/database.js'); if (!context.node || summaries.length === 0) { - await updateUserPeerPolicies(userId, null); + const success = updateUserPeerPolicies(userId, null); + if (!success) { + console.warn('Failed to clear peer policies for user', userId); + throw new Error('Failed to persist peer policies'); + } await saveFallbackPeerPolicies(null); return; } - const success = await updateUserPeerPolicies(userId, sanitizedPolicies); + const success = updateUserPeerPolicies(userId, sanitizedPolicies); if (!success) { console.warn('Failed to persist peer policies for user', userId); - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); - } else { - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + throw new Error('Failed to persist peer policies'); } + await saveFallbackPeerPolicies(fallbackPolicies); } catch (error) { console.error('Failed to persist peer policies:', error); - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + throw error; } } @@ -368,8 +372,8 @@ export async function handlePeersRoute(req: Request, url: URL, context: RouteCon }, { status: 400, headers }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Malformed credentials'; - return Response.json({ error: 'Malformed credentials', warnings: [message] }, { status: 400, headers }); + console.error('Failed to extract self pubkey from credentials:', error); + return Response.json({ error: 'Malformed credentials', warnings: ['Invalid credentials format'] }, { status: 400, headers }); } } break; @@ -643,10 +647,10 @@ async function handlePingAllPeers(context: RouteContext, headers: Record 0 ? retryAfterWindow : 900; + const retryAfterSeconds = retryAfterFromReset !== null ? retryAfterFromReset : retryAfterFallback; return Response.json({ code: 'RATE_LIMITED', error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() } diff --git a/src/routes/update.ts b/src/routes/update.ts index 90ff43c..ba743b3 100644 --- a/src/routes/update.ts +++ b/src/routes/update.ts @@ -40,7 +40,7 @@ interface UpdateResponse { const UPDATE_CHECK_TIMEOUT_MS = parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10) || 5000; const UPDATE_CHECK_TTL_MS = parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10) || 21_600_000; // 6 hours const UPDATE_CHECK_FAILURE_TTL_MS = parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10) || 900_000; // 15 minutes -const ALLOW_PRERELEASE_UPDATES = false; +const ALLOW_PRERELEASE_UPDATES = parseBoolean(process.env['ALLOW_PRERELEASE_UPDATES']); const GITHUB_OWNER = 'FROSTR-ORG'; const GITHUB_REPO = 'igloo-server'; diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 9d74261..5e1b538 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -251,7 +251,8 @@ export class PersistentRateLimiter { // Clear all buckets for this identifier const keys = Array.from(this.fallbackStore.keys()); for (const key of keys) { - const keyIdentifier = key.slice(key.lastIndexOf(':') + 1); + const separatorIndex = key.indexOf(':'); + const keyIdentifier = separatorIndex === -1 ? key : key.slice(separatorIndex + 1); if (keyIdentifier === identifier) { this.fallbackStore.delete(key); } diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 1c38aaf..7eeadbe 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -43,7 +43,7 @@ function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string if (!compact) return '(empty)'; const redacted = compact .replace( - /(["']?(?:admin_secret|session_secret|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, + /(["']?(?:admin_secret|session_secret|derived[_-]?key|encryption[_-]?key|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, '$1' ) .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); From 269376f78c29e8c5f192bf1af91f5aa8fd6e7527 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Sat, 28 Feb 2026 12:28:20 -0600 Subject: [PATCH 28/32] fix: apply requested API and UI hardening updates --- .github/workflows/ci.yml | 12 +- .github/workflows/release.yml | 60 +- .gitignore | 2 + compose.yml | 5 +- docs/CONFIG.md | 1 + docs/DEPLOY.md | 4 +- docs/RELEASE.md | 10 +- docs/SECURITY.md | 2 +- docs/openapi/openapi.json | 598 ++++++++++++++++++ docs/openapi/openapi.yaml | 513 ++++++++++++++- env.example | 11 +- frontend/App.tsx | 4 +- frontend/components/ApiKeys.tsx | 2 + frontend/components/Configure.tsx | 6 +- frontend/components/Login.tsx | 14 +- frontend/components/NIP46.tsx | 56 +- frontend/components/Onboarding.tsx | 1 - frontend/components/Recover.tsx | 81 ++- frontend/components/Signer.tsx | 75 ++- frontend/components/nip46/QRScanner.tsx | 23 +- frontend/components/nip46/RelaySettings.tsx | 16 +- frontend/components/ui/button.tsx | 7 +- frontend/components/ui/collapsible.tsx | 2 +- frontend/components/ui/icon-button.tsx | 2 +- frontend/components/ui/modal.tsx | 52 +- frontend/components/ui/peer-list.tsx | 37 +- frontend/components/ui/relay-input.tsx | 6 +- frontend/styles.css | 5 - frontend/tsconfig.json | 2 +- frontend/types/nostr-connect.d.ts | 8 +- llm/implementation/auth-implementation.md | 2 +- .../node-lifecycle-implementation.md | 3 +- llm/workflows/RELEASE_PROCESS.md | 20 +- package.json | 2 +- scripts/api/README.md | 1 + scripts/api/test-get-endpoints.ts | 3 +- scripts/api/test-ws-events.ts | 5 +- scripts/fetch-swagger-ui.mjs | 2 +- scripts/patch-zod-compat.mjs | 17 +- scripts/release.sh | 17 +- scripts/start-headless.js | 26 + src/class/relay.test.ts | 5 +- src/class/relay.ts | 4 +- src/const.ts | 1 - src/db/database.ts | 50 +- .../20250922_0007_add_nip46_relays.sql | 6 +- src/db/migrator.ts | 12 +- src/db/nip46.ts | 83 ++- src/nip46/index.ts | 16 + src/nip46/service.ts | 34 +- src/node/manager.ts | 165 ++--- src/routes/auth-factory.ts | 2 +- src/routes/auth.ts | 16 +- src/routes/docs.ts | 14 +- src/routes/env.ts | 174 ++--- src/routes/index.ts | 18 + src/routes/nip44.ts | 10 +- src/routes/nip46.ts | 167 ++++- src/routes/node-manager.ts | 7 + src/routes/onboarding.ts | 9 +- src/routes/peers.ts | 266 ++++++-- src/routes/sign.ts | 32 +- src/routes/user.ts | 8 +- src/routes/utils.ts | 37 +- src/server.ts | 15 +- src/types/global.d.ts | 6 + static/docs/swagger-ui-bundle.js | 3 - static/index.html | 4 +- tests/routes/admin.api-keys.negatives.spec.ts | 46 +- tests/routes/admin.whoami.session.spec.ts | 13 +- tests/routes/env.db-mode.spec.ts | 98 +-- tests/routes/helpers/script-runner.ts | 2 +- tests/routes/nip46.spec.ts | 44 ++ tests/routes/protected.api.spec.ts | 4 +- tests/routes/user-peers.spec.ts | 326 ++++++++++ 75 files changed, 2828 insertions(+), 584 deletions(-) create mode 100755 scripts/start-headless.js delete mode 100644 static/docs/swagger-ui-bundle.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7d416e..c10d3fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,13 +142,13 @@ jobs: - name: Test Docker image run: | + set -euo pipefail + trap 'docker stop test-container >/dev/null 2>&1 || true; docker rm test-container >/dev/null 2>&1 || true' EXIT docker run -d --name test-container -p 8002:8002 \ -e AUTO_ADMIN_SECRET=true \ igloo-server:test sleep 10 - curl -f http://localhost:8002/api/status || exit 1 - docker stop test-container - docker rm test-container + curl -f http://localhost:8002/api/status - name: Build Umbrel Docker image uses: docker/build-push-action@v5 @@ -162,12 +162,12 @@ jobs: - name: Test Umbrel Docker image run: | + set -euo pipefail + trap 'docker stop test-umbrel >/dev/null 2>&1 || true; docker rm test-umbrel >/dev/null 2>&1 || true' EXIT docker run -d --name test-umbrel -p 8003:8002 \ -e ADMIN_SECRET=ci-admin-secret \ -e ALLOWED_ORIGINS=http://localhost:8003 \ -e TRUST_PROXY=true \ igloo-server-umbrel:test sleep 10 - curl -f http://localhost:8003/api/status || exit 1 - docker stop test-umbrel - docker rm test-umbrel + curl -f http://localhost:8003/api/status diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1317cfe..9096905 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,25 +87,45 @@ jobs: echo "# CHANGELOG" > CHANGELOG.md echo "" >> CHANGELOG.md fi - - # Add new version entry + + VERSION="${{ steps.new_version.outputs.version_number }}" DATE=$(date +%Y-%m-%d) - sed -i "3i\\## [${{ steps.new_version.outputs.version_number }}] - $DATE\\n" CHANGELOG.md - - # Add commit messages since last tag + if ! grep -Fq "## [${VERSION}]" CHANGELOG.md; then + awk -v version="$VERSION" -v date="$DATE" ' + NR == 1 { print; print ""; print "## [" version "] - " date; print ""; next } + { print } + ' CHANGELOG.md > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md + fi + + temp_changelog="$(mktemp)" + + # Add commit messages since last tag (safe for special chars in subjects) LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -n "$LAST_TAG" ]; then - echo "### Changes since $LAST_TAG:" >> temp_changelog.md - git log --pretty=format:"- %s" $LAST_TAG..HEAD >> temp_changelog.md + printf '### Changes since %s:\n' "$LAST_TAG" > "$temp_changelog" + git log --pretty=format:'%s%x00' "$LAST_TAG..HEAD" \ + | tr '\0' '\n' \ + | awk 'NF { print "- " $0 }' >> "$temp_changelog" else - echo "### Changes:" >> temp_changelog.md - git log --pretty=format:"- %s" -n 10 >> temp_changelog.md + printf '### Changes:\n' > "$temp_changelog" + git log --pretty=format:'%s%x00' -n 10 \ + | tr '\0' '\n' \ + | awk 'NF { print "- " $0 }' >> "$temp_changelog" fi - echo "" >> temp_changelog.md - - # Insert changes into changelog - sed -i "/## \[${{ steps.new_version.outputs.version_number }}\]/r temp_changelog.md" CHANGELOG.md - rm temp_changelog.md + printf '\n' >> "$temp_changelog" + + # Insert changes into changelog after the current version heading + awk -v target="## [${VERSION}]" -v insert_file="$temp_changelog" ' + { print } + $0 == target && !inserted { + while ((getline line < insert_file) > 0) print line + close(insert_file) + inserted = 1 + } + ' CHANGELOG.md > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md + rm -f "$temp_changelog" - name: Create release archive run: | @@ -133,8 +153,14 @@ jobs: # Create git tag for release (works with branch protection) # Note: Version changes are not committed back to main due to branch protection # The release archives will contain the correct versions - git tag ${{ steps.new_version.outputs.new_version }} - git push origin ${{ steps.new_version.outputs.new_version }} + TAG="${{ steps.new_version.outputs.new_version }}" + git fetch --tags origin + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists, skipping tag creation" + else + git tag "${TAG}" + git push origin "${TAG}" + fi - name: Create GitHub release and upload assets uses: softprops/action-gh-release@v2 @@ -172,6 +198,8 @@ jobs: docker: runs-on: ubuntu-latest needs: release + permissions: + packages: write steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index eda8834..ee63590 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ dist static/app.js static/app.css static/styles.css +static/docs/swagger-ui-bundle.js +static/docs/swagger-ui-bundle.js.map static/qr-scanner-worker.min.js # VSCode diff --git a/compose.yml b/compose.yml index 96b5bb0..ebac7a3 100644 --- a/compose.yml +++ b/compose.yml @@ -15,8 +15,8 @@ services: - HOST_NAME=0.0.0.0 - HOST_PORT=8002 - NODE_ENV=production - # Explicit DB path inside the container; lives under /app/data - - DB_PATH=/app/data/igloo.db + # DB directory inside the container; database file defaults to /app/data/igloo.db + - DB_PATH=/app/data container_name: igloo-server hostname: igloo-server @@ -49,4 +49,3 @@ services: networks: infranet: driver: bridge - diff --git a/docs/CONFIG.md b/docs/CONFIG.md index f30b6c6..d9e6d04 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -90,6 +90,7 @@ Timeouts: WebSocket abuse controls: - `RATE_LIMIT_WS_UPGRADE_WINDOW`, `RATE_LIMIT_WS_UPGRADE_MAX` - `WS_MAX_CONNECTIONS_PER_IP`, `WS_MSG_RATE`, `WS_MSG_BURST` +- `ALLOW_QUERY_CREDENTIALS` (default `true` for legacy `/api/events?apiKey=...` / `sessionId` compatibility; set `false` to disable query-param auth on upgrades) Recovery throttling: - `RATE_LIMIT_RECOVERY_WINDOW`, `RATE_LIMIT_RECOVERY_MAX` diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ba2391c..611f4bb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -32,14 +32,14 @@ sudo chmod +x /usr/local/bin/docker-compose ``` 2) Pull and run (pin a release tag for reproducibility, e.g., `1.1.1` or `umbrel-1.1.1`): ```bash -docker pull ghcr.io/frostr-org/igloo-server:latest +docker pull ghcr.io/frostr-org/igloo-server:1.1.1 docker run -d --name igloo-server -p 8002:8002 \ -v $PWD/data:/app/data \ -e ADMIN_SECRET=$(openssl rand -hex 32) \ -e AUTH_ENABLED=true \ -e TRUST_PROXY=true \ -e ALLOWED_ORIGINS=https://yourdomain.com \ - ghcr.io/frostr-org/igloo-server:latest + ghcr.io/frostr-org/igloo-server:1.1.1 ``` 3) Docker Compose option (create `docker-compose.yml`): ```yaml diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ab3ef2c..400a61e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -37,10 +37,10 @@ bun run docs:validate git checkout -b release/prepare-v1.1.1 git push origin release/prepare-v1.1.1 ``` -Create PR: `release/prepare-v1.1.1` → `master` +Create PR: `release/prepare-v1.1.1` → `main` ### 3. Merge & Release -- Merge PR to `master` +- Merge PR to `main` - GitHub Actions automatically: - Bumps version in `package.json` - Updates `CHANGELOG.md` @@ -50,7 +50,7 @@ Create PR: `release/prepare-v1.1.1` → `master` ### 4. Verify Release - ✅ Check [GitHub Releases](https://github.com/FROSTR-ORG/igloo-server/releases) - ✅ Test Docker image: `docker pull ghcr.io/frostr-org/igloo-server:latest` -- ✅ Sync dev: `git checkout dev && git merge master && git push origin dev` +- ✅ Sync dev: `git checkout dev && git merge main && git push origin dev` ## 🔄 Version Bumping Logic @@ -66,11 +66,11 @@ GitHub Actions automatically detects version type from commit messages: For critical fixes: ```bash -git checkout master +git checkout main git checkout -b hotfix/critical-fix # Make fix and commit git push origin hotfix/critical-fix -# Create PR to master +# Create PR to main ``` ## 📦 Release Artifacts diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 43a2abe..95d80e5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -647,7 +647,7 @@ bun run start # 2. Test with API key authentication AUTH_ENABLED=true API_KEY= # Replace with test API key -curl -H "X-API-Key: test-api-key-12345" http://localhost:8002/api/status +curl -H "X-API-Key: " http://localhost:8002/api/status # 3. Test with basic authentication AUTH_ENABLED=true diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 2653b2c..61d362d 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -85,6 +85,14 @@ { "name": "Event Log", "description": "Persisted UI event log endpoints (database mode only)" + }, + { + "name": "Crypto", + "description": "Cryptographic operations and key management" + }, + { + "name": "NIP‑46", + "description": "NIP‑46 protocol and signing interactions" } ], "paths": { @@ -2098,6 +2106,426 @@ } } }, + "/api/nip46/requests": { + "get": { + "operationId": "listNip46Requests", + "summary": "List persisted NIP‑46 requests", + "tags": [ + "NIP‑46" + ], + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + }, + "description": "Comma-separated request statuses (pending, approved, denied, completed, failed, expired)" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "description": "Number of requests to return (default 100)" + }, + { + "in": "query", + "name": "beforeCreatedAt", + "schema": { + "type": "string" + }, + "description": "Cursor created_at value for pagination (must be paired with beforeId)" + }, + { + "in": "query", + "name": "beforeId", + "schema": { + "type": "string" + }, + "description": "Cursor id value for pagination (must be paired with beforeCreatedAt)" + } + ], + "responses": { + "200": { + "description": "Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Nip46Request" + } + }, + "nextCursor": { + "oneOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "operationId": "updateNip46Request", + "summary": "Update a NIP‑46 request status", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RequestActionInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request": { + "$ref": "#/components/schemas/Nip46Request" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "operationId": "deleteNip46Request", + "summary": "Delete a persisted NIP‑46 request", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RequestDeleteInput" + } + } + } + }, + "responses": { + "200": { + "description": "Request deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/nip46/connect": { + "post": { + "operationId": "createNip46Connect", + "summary": "Process a nostrconnect invite string", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46ConnectInput" + } + } + } + }, + "responses": { + "200": { + "description": "Session created/updated from invite", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46ConnectResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/nip46/relays": { + "get": { + "operationId": "listNip46Relays", + "summary": "Get NIP‑46 relay pool", + "tags": [ + "NIP‑46" + ], + "responses": { + "200": { + "description": "Relay list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "relays": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "put": { + "operationId": "updateNip46Relays", + "summary": "Replace NIP‑46 relay pool", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RelaysInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated relays", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "relays": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "operationId": "mergeNip46Relays", + "summary": "Merge relays into NIP‑46 relay pool", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RelaysInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated relays", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "relays": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/nip46/transport": { + "get": { + "operationId": "getNip46Transport", + "summary": "Get NIP‑46 transport key", + "tags": [ + "NIP‑46" + ], + "responses": { + "200": { + "description": "Transport key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46Transport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "put": { + "operationId": "updateNip46Transport", + "summary": "Set or rotate NIP‑46 transport key", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46Transport" + } + } + } + }, + "responses": { + "200": { + "description": "Updated transport key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "transport_sk": { + "type": "string" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/api/admin/api-keys": { "get": { "operationId": "listAdminApiKeys", @@ -3671,6 +4099,176 @@ } } }, + "Nip46Request": { + "type": "object", + "description": "Persisted NIP‑46 request payload and lifecycle status", + "properties": { + "id": { + "type": "string" + }, + "user_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "session_pubkey": { + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "approved", + "denied", + "completed", + "failed", + "expired" + ] + }, + "result": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "expires_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "user_id", + "session_pubkey", + "method", + "params", + "status", + "created_at", + "updated_at" + ] + }, + "Nip46RequestActionInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Persisted request identifier" + }, + "action": { + "type": "string", + "enum": [ + "approve", + "deny", + "fail", + "complete" + ] + }, + "policy": { + "$ref": "#/components/schemas/Nip46Policy" + }, + "result": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "action" + ] + }, + "Nip46RequestDeleteInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "Nip46ConnectInput": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "nostrconnect:// invite URI" + } + }, + "required": [ + "uri" + ] + }, + "Nip46ConnectResponse": { + "type": "object", + "properties": { + "session": { + "$ref": "#/components/schemas/Nip46Session" + } + } + }, + "Nip46RelaysInput": { + "type": "object", + "properties": { + "relays": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "required": [ + "relays" + ] + }, + "Nip46Transport": { + "type": "object", + "properties": { + "transport_sk": { + "type": "string", + "description": "32-byte transport secret key encoded as hex" + } + }, + "required": [ + "transport_sk" + ] + }, "AdminApiKey": { "type": "object", "description": "Metadata about an API key managed in database mode", diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c2eea82..1b69498 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -54,12 +54,7 @@ servers: description: Local development server (HTTP for local development only) x-internal-only: true -security: - - apiKeyAuth: [] - - bearerAuth: [] - - basicAuth: [] - - sessionCookie: [] - - sessionHeader: [] +security: [] paths: /api/status: @@ -132,6 +127,12 @@ paths: - The stream emits JSON objects shaped like `{ type, message, data?, timestamp, id }`. tags: - Events + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: | @@ -187,6 +188,12 @@ paths: - Use `types` (comma-separated) to filter by event type. tags: - Event Log + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - name: limit in: query @@ -234,6 +241,12 @@ paths: The UI typically loads this lazily when a log entry is expanded. tags: - Event Log + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - name: hash in: path @@ -269,6 +282,12 @@ paths: - `types`: comma-separated list of event types to include. tags: - Event Log + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - name: sinceSeq in: query @@ -422,6 +441,12 @@ paths: description: Retrieve current environment configuration (whitelisted variables only) tags: - Configuration + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Environment variables retrieved successfully @@ -445,6 +470,12 @@ paths: description: Update environment configuration (whitelisted variables only) tags: - Configuration + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -495,6 +526,12 @@ paths: description: Remove specified environment variables tags: - Configuration + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -547,6 +584,12 @@ paths: description: Return the signing group public key and quorum information tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Group metadata retrieved successfully @@ -584,6 +627,12 @@ paths: description: Get all peers from the group credential with their current status tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Peers retrieved successfully @@ -602,12 +651,12 @@ paths: type: integer example: peers: - - pubkey: "02abcd1234..." + - pubkey: "021111111111111111111111111111111111111111111111111111111111111111" online: true lastSeen: "2025-01-20T12:00:00.000Z" latency: 150 lastPingAttempt: "2025-01-20T11:59:00.000Z" - - pubkey: "03efgh5678..." + - pubkey: "032222222222222222222222222222222222222222222222222222222222222222" online: false lastSeen: null latency: null @@ -632,6 +681,12 @@ paths: description: Get the public key of this node from the share credential tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Self public key retrieved successfully @@ -668,6 +723,12 @@ paths: description: Ping specific peer or all peers to check connectivity tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -755,6 +816,12 @@ paths: description: Use threshold shares to recover the original secret key tags: - Key Recovery + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -859,6 +926,12 @@ paths: description: Validate group or share credentials without performing recovery tags: - Key Recovery + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -940,6 +1013,12 @@ paths: description: Retrieve currently stored share information when running in headless mode tags: - Share Management + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Shares retrieved successfully @@ -981,6 +1060,12 @@ paths: description: Save share and group credentials when running in headless mode tags: - Share Management + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -1222,6 +1307,12 @@ paths: operationId: listNip46Sessions summary: List NIP‑46 sessions tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - in: query name: history @@ -1247,6 +1338,12 @@ paths: operationId: upsertNip46Session summary: Create or update a NIP‑46 session tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -1273,6 +1370,12 @@ paths: operationId: updateNip46Policy summary: Update session policy tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - $ref: '#/components/parameters/PubkeyParam' requestBody: @@ -1290,6 +1393,12 @@ paths: operationId: updateNip46Status summary: Update session status (revoked deletes) tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - $ref: '#/components/parameters/PubkeyParam' requestBody: @@ -1314,6 +1423,12 @@ paths: operationId: deleteNip46Session summary: Delete a NIP‑46 session tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - $ref: '#/components/parameters/PubkeyParam' responses: @@ -1335,6 +1450,12 @@ paths: operationId: nip46History summary: Compact NIP‑46 history summary tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: History @@ -1350,6 +1471,289 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/nip46/requests: + get: + operationId: listNip46Requests + summary: List persisted NIP‑46 requests + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + parameters: + - in: query + name: status + schema: + type: string + description: Comma-separated request statuses (pending, approved, denied, completed, failed, expired) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + description: Number of requests to return (default 100) + - in: query + name: beforeCreatedAt + schema: + type: string + description: Cursor created_at value for pagination (must be paired with beforeId) + - in: query + name: beforeId + schema: + type: string + description: Cursor id value for pagination (must be paired with beforeCreatedAt) + responses: + '200': + description: Requests + content: + application/json: + schema: + type: object + properties: + requests: + type: array + items: + $ref: '#/components/schemas/Nip46Request' + nextCursor: + oneOf: + - type: object + properties: + createdAt: { type: string } + id: { type: string } + - type: 'null' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: updateNip46Request + summary: Update a NIP‑46 request status + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RequestActionInput' + responses: + '200': + description: Updated request + content: + application/json: + schema: + type: object + properties: + request: + $ref: '#/components/schemas/Nip46Request' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteNip46Request + summary: Delete a persisted NIP‑46 request + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RequestDeleteInput' + responses: + '200': + description: Request deleted + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': { $ref: '#/components/responses/NotFound' } + + /api/nip46/connect: + post: + operationId: createNip46Connect + summary: Process a nostrconnect invite string + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46ConnectInput' + responses: + '200': + description: Session created/updated from invite + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46ConnectResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + + /api/nip46/relays: + get: + operationId: listNip46Relays + summary: Get NIP‑46 relay pool + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + responses: + '200': + description: Relay list + content: + application/json: + schema: + type: object + properties: + relays: + type: array + items: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: updateNip46Relays + summary: Replace NIP‑46 relay pool + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RelaysInput' + responses: + '200': + description: Updated relays + content: + application/json: + schema: + type: object + properties: + relays: + type: array + items: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: mergeNip46Relays + summary: Merge relays into NIP‑46 relay pool + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RelaysInput' + responses: + '200': + description: Updated relays + content: + application/json: + schema: + type: object + properties: + relays: + type: array + items: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/nip46/transport: + get: + operationId: getNip46Transport + summary: Get NIP‑46 transport key + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + responses: + '200': + description: Transport key + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46Transport' + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: updateNip46Transport + summary: Set or rotate NIP‑46 transport key + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46Transport' + responses: + '200': + description: Updated transport key + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + transport_sk: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + /api/admin/api-keys: get: operationId: listAdminApiKeys @@ -1800,6 +2204,8 @@ components: description: | API key authentication via X-API-Key header. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory in production environments - API keys must be transmitted over encrypted connections only @@ -1814,6 +2220,8 @@ components: description: | Bearer token authentication (alternative to X-API-Key header). + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory in production environments - Bearer tokens must be transmitted over encrypted connections only @@ -1828,6 +2236,8 @@ components: description: | HTTP Basic Authentication using username and password. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is absolutely critical - Basic Auth transmits credentials in Base64 encoding (not encryption) - Never use Basic Auth over HTTP in production as credentials are easily intercepted @@ -1843,6 +2253,8 @@ components: description: | Session-based authentication via the `X-Session-ID` header. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory to protect session identifiers from interception - Treat the header value like a credential and avoid logging it @@ -1858,6 +2270,8 @@ components: description: | Session-based authentication via HttpOnly cookie. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory so cookies are only sent over encrypted channels - Cookies should be issued with Secure, HttpOnly, and SameSite attributes where appropriate @@ -2409,6 +2823,85 @@ components: type: array items: { type: string } + Nip46Request: + type: object + description: Persisted NIP‑46 request payload and lifecycle status + properties: + id: { type: string } + user_id: + oneOf: + - type: integer + - type: string + session_pubkey: { type: string } + method: { type: string } + params: { type: string } + status: + type: string + enum: [pending, approved, denied, completed, failed, expired] + result: + type: [string, 'null'] + error: + type: [string, 'null'] + created_at: { type: string } + updated_at: { type: string } + expires_at: + type: [string, 'null'] + required: [id, user_id, session_pubkey, method, params, status, created_at, updated_at] + + Nip46RequestActionInput: + type: object + properties: + id: + type: string + description: Persisted request identifier + action: + type: string + enum: [approve, deny, fail, complete] + policy: + $ref: '#/components/schemas/Nip46Policy' + result: + type: [string, 'null'] + error: + type: [string, 'null'] + required: [id, action] + + Nip46RequestDeleteInput: + type: object + properties: + id: + type: string + required: [id] + + Nip46ConnectInput: + type: object + properties: + uri: + type: string + description: nostrconnect:// invite URI + required: [uri] + + Nip46ConnectResponse: + type: object + properties: + session: + $ref: '#/components/schemas/Nip46Session' + + Nip46RelaysInput: + type: object + properties: + relays: + type: ['array', 'null'] + items: { type: string } + required: [relays] + + Nip46Transport: + type: object + properties: + transport_sk: + type: string + description: 32-byte transport secret key encoded as hex + required: [transport_sk] + AdminApiKey: type: object description: Metadata about an API key managed in database mode @@ -2745,3 +3238,7 @@ tags: description: First-run onboarding and admin validation (database mode) - name: Event Log description: Persisted UI event log endpoints (database mode only) + - name: Crypto + description: Cryptographic operations and key management + - name: NIP‑46 + description: NIP‑46 protocol and signing interactions diff --git a/env.example b/env.example index 9eff174..83e02a1 100644 --- a/env.example +++ b/env.example @@ -151,6 +151,11 @@ NIP46_SESSION_RATE_LIMIT_MAX=120 # WS_MAX_CONNECTIONS_PER_IP=5 # WS_MSG_RATE=20 # WS_MSG_BURST=40 +# +# Legacy WebSocket auth compatibility for `/api/events`: +# - true (default): allow `?apiKey=` / `?sessionId=` query params on upgrades. +# - false: require headers or `Sec-WebSocket-Protocol` auth hints instead. +# ALLOW_QUERY_CREDENTIALS=true # ============================================================================= # RECOVERY RATE LIMITS (ADVANCED) @@ -252,7 +257,7 @@ NODE_ENV=development # PERSONAL (Home server - medium security) # AUTH_ENABLED=true # API_KEY=personal-server-key-2024 -# SESSION_SECRET=4f7c1e9a2b3d4c5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6 +# SESSION_SECRET= # SESSION_TIMEOUT=7200 # RATE_LIMIT_MAX=50 @@ -261,7 +266,7 @@ NODE_ENV=development # BASIC_AUTH_USER=teamadmin # BASIC_AUTH_PASS=SecureTeamPassword123! # API_KEY=team-automation-key-64chars -# SESSION_SECRET=9a8b7c6d5e4f3021a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0 +# SESSION_SECRET= # SESSION_TIMEOUT=3600 # RATE_LIMIT_MAX=100 @@ -271,7 +276,7 @@ NODE_ENV=development # BASIC_AUTH_USER=prodadmin # BASIC_AUTH_PASS=VerySecurePassword456! # API_KEY=prod-api-key-with-64-random-chars-abcdef123456789 -# SESSION_SECRET=0123456789abcdef0123456789abcdefabcdef0123456789abcdef0123456789 +# SESSION_SECRET= # SESSION_TIMEOUT=1800 # RATE_LIMIT_ENABLED=true # RATE_LIMIT_WINDOW=300 diff --git a/frontend/App.tsx b/frontend/App.tsx index 0ad15c7..782426e 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -311,7 +311,9 @@ const App: React.FC = () => { setSignerData(prev => prev ?? { share: '', groupCredential: '', name: 'Server credentials' }); } } - } catch {} + } catch (error) { + console.error('Failed to fetch /api/status while checking headless fallback state:', error); + } } } // If no saved credentials, we'll show Configure page (default state) diff --git a/frontend/components/ApiKeys.tsx b/frontend/components/ApiKeys.tsx index 7839f1a..a248ab5 100644 --- a/frontend/components/ApiKeys.tsx +++ b/frontend/components/ApiKeys.tsx @@ -226,6 +226,8 @@ const ApiKeys: React.FC = ({ authHeaders = {}, headlessMode = fals useEffect(() => { if (!isAdminUser) { initialAdminLoadRef.current = false + setKeys([]) + setIssuedKey(null) return } if (initialAdminLoadRef.current) return diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index 67a69ab..bdbdd2f 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -41,7 +41,7 @@ const defaultAdvancedSettings: AdvancedSettingsState = { interface ConfigureProps { onKeysetCreated: (data: { groupCredential: string; shareCredentials: string[]; name: string }) => void; - onCredentialsSaved?: () => void; + onCredentialsSaved?: () => void | Promise; onBack?: () => void; authHeaders?: Record; } @@ -337,7 +337,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav useEffect(() => { if (!showClearConfirm) return; const previousFocus = (document.activeElement as HTMLElement | null) ?? clearTriggerButtonRef.current; - const focusPrimary = () => clearConfirmButtonRef.current?.focus(); + const focusPrimary = () => clearCancelButtonRef.current?.focus(); const raf = window.requestAnimationFrame(focusPrimary); const handleKeyDown = (event: KeyboardEvent) => { @@ -569,7 +569,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav // Notify parent component to refresh views if (onCredentialsSaved) { - onCredentialsSaved(); + await onCredentialsSaved(); } // Clear the form diff --git a/frontend/components/Login.tsx b/frontend/components/Login.tsx index 16a4a2b..e1f9c5c 100644 --- a/frontend/components/Login.tsx +++ b/frontend/components/Login.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { PageLayout } from './ui/page-layout'; @@ -33,11 +33,7 @@ const Login: React.FC = ({ onLogin, authEnabled, updateInfo }) => { const [authStatus, setAuthStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); - useEffect(() => { - fetchAuthStatus(); - }, []); - - const fetchAuthStatus = async () => { + const fetchAuthStatus = useCallback(async () => { try { const response = await fetch('/api/auth/status'); if (response.ok) { @@ -62,7 +58,11 @@ const Login: React.FC = ({ onLogin, authEnabled, updateInfo }) => { } finally { setStatusLoading(false); } - }; + }, []); + + useEffect(() => { + void fetchAuthStatus(); + }, [fetchAuthStatus]); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/components/NIP46.tsx b/frontend/components/NIP46.tsx index e93db5c..1f20958 100644 --- a/frontend/components/NIP46.tsx +++ b/frontend/components/NIP46.tsx @@ -107,14 +107,21 @@ export function NIP46({ authHeaders }: NIP46Props) { const fetchTransport = useCallback(async () => { try { const res = await fetch('/api/nip46/transport', { headers }) - if (res.ok) { - const data = await res.json() - if (typeof data?.transport_sk === 'string') { - setTransportKey(data.transport_sk) - setIsConnected(true) - } + if (!res.ok) { + setTransportKey(null) + setIsConnected(false) + return + } + const data = await res.json() + if (typeof data?.transport_sk === 'string') { + setTransportKey(data.transport_sk) + setIsConnected(true) + } else { + setTransportKey(null) + setIsConnected(false) } } catch { + setTransportKey(null) setIsConnected(false) } }, [headers]) @@ -127,8 +134,11 @@ export function NIP46({ authHeaders }: NIP46Props) { if (!targets.length) return setRequestsError(null) setRequestActionPending(true) + const extractErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : 'Failed to update request' + } try { - await Promise.all(targets.map(async target => { + const results = await Promise.allSettled(targets.map(async target => { const payload: Record = { id: target.id, action } if (options?.policyPatch) { payload.policy = options.policyPatch @@ -147,9 +157,14 @@ export function NIP46({ authHeaders }: NIP46Props) { if (options?.policyPatch) { await fetchSessions() } + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => extractErrorMessage(result.reason)) + if (errors.length > 0) { + setRequestsError(errors.join('; ')) + } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update request' - setRequestsError(message) + setRequestsError(extractErrorMessage(error)) } finally { setRequestActionPending(false) } @@ -411,17 +426,22 @@ export function NIP46({ authHeaders }: NIP46Props) {
-
- - {sessions.length} {sessions.length === 1 ? 'session' : 'sessions'} -
+
+ + {sessions.length} {sessions.length === 1 ? 'session' : 'sessions'} +
{requests.filter(r => r.status === 'pending').length} pending requests
-
@@ -434,7 +454,13 @@ export function NIP46({ authHeaders }: NIP46Props) { {showFullKeys ? transportKey : truncate(transportKey, 8)} - diff --git a/frontend/components/Onboarding.tsx b/frontend/components/Onboarding.tsx index 38c473a..803bbae 100644 --- a/frontend/components/Onboarding.tsx +++ b/frontend/components/Onboarding.tsx @@ -560,7 +560,6 @@ const Onboarding: React.FC = ({ onComplete, initialSkipAdminVal onChange={(e) => setPassword(e.target.value)} disabled={isLoading} className="bg-gray-800/50 border-gray-700/50 text-blue-300 placeholder:text-gray-500" - pattern={PASSWORD_REGEX.source} title="Minimum 8 characters, with at least one uppercase letter, one lowercase letter, one number, and one special character" /> diff --git a/frontend/components/Recover.tsx b/frontend/components/Recover.tsx index 0bf659a..5ce8a71 100644 --- a/frontend/components/Recover.tsx +++ b/frontend/components/Recover.tsx @@ -98,6 +98,9 @@ const Recover: React.FC = ({ message: null }); const [isProcessing, setIsProcessing] = useState(false); + const [recoveredNsec, setRecoveredNsec] = useState(null); + const [showRecoveredNsec, setShowRecoveredNsec] = useState(false); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); // Add state for the dynamic threshold const [currentThreshold, setCurrentThreshold] = useState(defaultThreshold); @@ -377,6 +380,9 @@ const Recover: React.FC = ({ if (!sharesFormValid) return; + setRecoveredNsec(null); + setShowRecoveredNsec(false); + setCopyStatus('idle'); setIsProcessing(true); try { // Get valid share credentials @@ -403,33 +409,33 @@ const Recover: React.FC = ({ } if (result.success) { + setRecoveredNsec(typeof result.nsec === 'string' ? result.nsec : null); setResult({ success: true, - message: ( -
-
- Successfully recovered NSEC using {result.details.sharesUsed} shares -
-
-
-
Recovered NSEC:
-
- {result.nsec} -
+ message: `Successfully recovered NSEC using ${result.details.sharesUsed} shares` + }); + if (Array.isArray(result.details.invalidShares) && result.details.invalidShares.length > 0) { + setResult({ + success: true, + message: ( +
+
+ Successfully recovered NSEC using {result.details.sharesUsed} shares +
+
+ Note: {result.details.invalidShares.length} invalid shares were ignored
- {result.details.invalidShares && ( -
- Note: {result.details.invalidShares.length} invalid shares were ignored -
- )}
-
- ) - }); + ) + }); + } } else { throw new Error(result.error || 'Recovery failed'); } } catch (error) { + setRecoveredNsec(null); + setShowRecoveredNsec(false); + setCopyStatus('idle'); setResult({ success: false, message: `Error recovering NSEC: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -439,6 +445,17 @@ const Recover: React.FC = ({ } }; + const handleCopyRecoveredNsec = async () => { + if (!recoveredNsec) return; + try { + await navigator.clipboard.writeText(recoveredNsec); + setCopyStatus('copied'); + } catch (error) { + console.error('Failed to copy recovered NSEC:', error); + setCopyStatus('error'); + } + }; + return (
@@ -541,6 +558,32 @@ const Recover: React.FC = ({ result.success ? 'bg-green-900/30 text-green-200' : 'bg-red-900/30 text-red-200' }`}> {result.message} + {result.success && recoveredNsec && ( +
+
Recovered NSEC:
+
+ {showRecoveredNsec ? recoveredNsec : '••••••••••••••••••••••••••••••••'} +
+
+ + + {copyStatus === 'copied' && Copied} + {copyStatus === 'error' && Copy failed} +
+
+ )}
)}
diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index 2f2d3c0..0067841 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -49,6 +49,14 @@ const DEFAULT_RELAY = "wss://relay.primal.net"; const MAX_EVENT_LOG_IN_MEMORY = 10000; const AUTO_EXPAND_EVENT_TYPES: string[] = ['sign']; +function areRelayListsEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +} + const sanitizeLogEntry = (entry: unknown): LogEntryData | null => { if (!entry || typeof entry !== "object") return null; const log = entry as Partial; @@ -182,11 +190,14 @@ const Signer = forwardRef(({ initialData, authHeaders const [loadingOlder, setLoadingOlder] = useState(false); const [downloadingLogs, setDownloadingLogs] = useState(false); const [realSelfPubkey, setRealSelfPubkey] = useState(null); + const relayMutationIdRef = useRef(0); + const relayUrlsRef = useRef([DEFAULT_RELAY]); // Reference for compatibility with parent component const nodeRef = useRef(null); const authHeadersRef = useRef(authHeaders); useEffect(() => { authHeadersRef.current = authHeaders; }, [authHeaders]); + useEffect(() => { relayUrlsRef.current = relayUrls; }, [relayUrls]); // Expose methods to parent components through ref useImperativeHandle(ref, () => ({ @@ -679,7 +690,7 @@ const Signer = forwardRef(({ initialData, authHeaders setRelayUrls(relays); } else { // If no valid relays found, save default relays - saveRelaysToEnv([DEFAULT_RELAY]); + void saveRelaysToEnv([DEFAULT_RELAY]); } } catch (error) { console.warn('Failed to parse RELAYS from env:', error); @@ -688,12 +699,12 @@ const Signer = forwardRef(({ initialData, authHeaders setRelayUrls([envVars.RELAYS]); } else { // Save default relays if parsing failed - saveRelaysToEnv([DEFAULT_RELAY]); + void saveRelaysToEnv([DEFAULT_RELAY]); } } } else { // If no RELAYS environment variable exists, save the default - saveRelaysToEnv([DEFAULT_RELAY]); + void saveRelaysToEnv([DEFAULT_RELAY]); } } catch (error) { console.error('Error fetching environment variables:', error); @@ -993,28 +1004,56 @@ const Signer = forwardRef(({ initialData, authHeaders }; // Save relay URLs (routes to appropriate endpoint based on mode) - const saveRelaysToEnv = async (relays: string[]) => { + const saveRelaysToEnv = async (relays: string[]): Promise => { if (isDatabaseMode()) { - await saveRelaysToUserCredentials(relays); + return await saveRelaysToUserCredentials(relays); } else { - await saveRelaysToServerEnv(relays); + return await saveRelaysToServerEnv(relays); } }; - const handleAddRelay = () => { - const isAlreadyAdded = relayUrls.indexOf(newRelayUrl) !== -1; - if (newRelayUrl && !isAlreadyAdded) { - const newRelays = [...relayUrls, newRelayUrl]; - setRelayUrls(newRelays); - setNewRelayUrl(""); - saveRelaysToEnv(newRelays); + const handleAddRelay = async () => { + const relayToAdd = newRelayUrl.trim(); + const currentRelays = relayUrlsRef.current; + const isAlreadyAdded = currentRelays.indexOf(relayToAdd) !== -1; + if (!relayToAdd || isAlreadyAdded) return; + + const previousRelays = currentRelays; + const newRelays = [...currentRelays, relayToAdd]; + const mutationId = ++relayMutationIdRef.current; + relayUrlsRef.current = newRelays; + setRelayUrls(newRelays); + setNewRelayUrl(""); + + const saved = await saveRelaysToEnv(newRelays); + if (!saved) { + const isLatestMutation = mutationId === relayMutationIdRef.current; + const relaysStillMatchFailedAttempt = areRelayListsEqual(relayUrlsRef.current, newRelays); + if (isLatestMutation && relaysStillMatchFailedAttempt) { + relayUrlsRef.current = previousRelays; + setRelayUrls(previousRelays); + setNewRelayUrl(relayToAdd); + } } }; - const handleRemoveRelay = (urlToRemove: string) => { - const newRelays = relayUrls.filter(url => url !== urlToRemove); + const handleRemoveRelay = async (urlToRemove: string) => { + const currentRelays = relayUrlsRef.current; + const newRelays = currentRelays.filter(url => url !== urlToRemove); + if (newRelays.length === currentRelays.length) return; + const previousRelays = currentRelays; + const mutationId = ++relayMutationIdRef.current; + relayUrlsRef.current = newRelays; setRelayUrls(newRelays); - saveRelaysToEnv(newRelays); + const saved = await saveRelaysToEnv(newRelays); + if (!saved) { + const isLatestMutation = mutationId === relayMutationIdRef.current; + const relaysStillMatchFailedAttempt = areRelayListsEqual(relayUrlsRef.current, newRelays); + if (isLatestMutation && relaysStillMatchFailedAttempt) { + relayUrlsRef.current = previousRelays; + setRelayUrls(previousRelays); + } + } }; // Expose the stopSigner method for compatibility (server-managed, no action needed) @@ -1303,7 +1342,7 @@ const Signer = forwardRef(({ initialData, authHeaders className="bg-gray-800/50 border-gray-700/50 text-blue-300 py-2 text-sm w-full" />
)} -
+ {!title && showCloseButton && ( +
+ +
+ )} +
{children}
@@ -82,4 +116,4 @@ const Modal: React.FC = ({ ); }; -export { Modal }; \ No newline at end of file +export { Modal }; diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 1ba28b5..cbd891f 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -150,8 +150,8 @@ const derivePolicyState = ( }; const hasCustomPolicy = (policy: PeerPolicy): boolean => { - const sendOverride = typeof policy.allowSend === 'boolean' && policy.allowSend === false; - const receiveOverride = typeof policy.allowReceive === 'boolean' && policy.allowReceive === false; + const sendOverride = typeof policy.allowSend === 'boolean'; + const receiveOverride = typeof policy.allowReceive === 'boolean'; return sendOverride || receiveOverride; }; @@ -380,24 +380,34 @@ const PeerList: React.FC = ({ const updated = prev.map(peer => { // Try exact match first if (peer.pubkey === pubkey) { + const hasLatency = status.latency !== undefined && status.latency !== null; + const parsedLatency = Number(status.latency); + const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : peer.latency; + const parsedLastSeen = parseDate(status.lastSeen); + const parsedLastPingAttempt = parseDate(status.lastPingAttempt); return { ...peer, online: Boolean(status.online), - lastSeen: parseDate(status.lastSeen) ?? peer.lastSeen, - latency: status.latency ? Number(status.latency) : peer.latency, - lastPingAttempt: parseDate(status.lastPingAttempt) ?? peer.lastPingAttempt + lastSeen: parsedLastSeen ?? peer.lastSeen, + latency, + lastPingAttempt: parsedLastPingAttempt ?? peer.lastPingAttempt } as PeerStatus; } // Try match with compressed-prefix normalization (02/03) const peerNormalized = toPolicyKey(peer.pubkey); const pingNormalized = toPolicyKey(pubkey); if (peerNormalized !== '' && peerNormalized === pingNormalized) { + const hasLatency = status.latency !== undefined && status.latency !== null; + const parsedLatency = Number(status.latency); + const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : peer.latency; + const parsedLastSeen = parseDate(status.lastSeen); + const parsedLastPingAttempt = parseDate(status.lastPingAttempt); return { ...peer, online: Boolean(status.online), - lastSeen: parseDate(status.lastSeen) ?? peer.lastSeen, - latency: status.latency ? Number(status.latency) : peer.latency, - lastPingAttempt: parseDate(status.lastPingAttempt) ?? peer.lastPingAttempt + lastSeen: parsedLastSeen ?? peer.lastSeen, + latency, + lastPingAttempt: parsedLastPingAttempt ?? peer.lastPingAttempt } as PeerStatus; } return peer; @@ -439,15 +449,20 @@ const PeerList: React.FC = ({ const result = await response.json(); if (result.status) { + const hasLatency = result.status.latency !== undefined && result.status.latency !== null; + const parsedLatency = Number(result.status.latency); + const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : null; + const parsedLastSeen = parseDate(result.status.lastSeen); + const parsedLastPingAttempt = parseDate(result.status.lastPingAttempt); // Update peer status immediately setPeers(prev => prev.map(peer => peer.pubkey === peerPubkey ? { ...peer, online: Boolean(result.status.online), - lastSeen: parseDate(result.status.lastSeen) ?? peer.lastSeen, - latency: result.status.latency ? Number(result.status.latency) : peer.latency, - lastPingAttempt: parseDate(result.status.lastPingAttempt) ?? peer.lastPingAttempt + lastSeen: parsedLastSeen ?? peer.lastSeen, + latency: latency ?? peer.latency, + lastPingAttempt: parsedLastPingAttempt ?? peer.lastPingAttempt } as PeerStatus : peer )); diff --git a/frontend/components/ui/relay-input.tsx b/frontend/components/ui/relay-input.tsx index df7b61a..e53506c 100644 --- a/frontend/components/ui/relay-input.tsx +++ b/frontend/components/ui/relay-input.tsx @@ -121,8 +121,8 @@ const RelayInput: React.FC = ({
- {relays.map((relay, index) => ( -
+ {relays.map((relay) => ( +
{relay}