diff --git a/.github/workflows/update-visual-baselines.yml b/.github/workflows/update-visual-baselines.yml new file mode 100644 index 00000000..13137606 --- /dev/null +++ b/.github/workflows/update-visual-baselines.yml @@ -0,0 +1,95 @@ +# Regenerate the visual-regression baselines IN the environment that compares +# them. +# +# The baselines in tests/visual/baselines.json are pixel-compared on +# ubuntu-latest CI, but they were historically captured on a developer machine. +# Chromium builds and font fallbacks differ between the two ("Inter"/"Fira +# Code" are not shipped or installed anywhere, so every environment renders its +# own system fallback), so the kitchen-sink diff drifted past MAX_DIFF_RATIO on +# CI while passing locally — ci.yml even documents the mismatch as the reason +# visual-regression must not block deploys. Capturing the baselines on the same +# runner image, browser install and font stack as the comparison removes the +# environment variable from the equation entirely. +# +# Two explicit triggers, both requiring deliberate human intent — regenerating +# baselines must never be a side effect of ordinary work: +# +# 1. workflow_dispatch — only usable once this file reaches the default +# branch (GitHub does not index dispatchable workflows on other branches). +# 2. A push whose head commit message contains [update-visual-baselines] — +# works from unstable today, before any promotion. The workflow's own +# commit omits the token, so it cannot retrigger itself. +name: Update visual baselines + +on: + workflow_dispatch: + push: + branches: [unstable] + +permissions: + contents: write + +jobs: + update-baselines: + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.head_commit.message, '[update-visual-baselines]') + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + + # Mirror the visual-regression job's caches so the regenerated baselines + # come from the same dependency tree the comparison job will use. + - name: Cache npm directory + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-22-npm-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-22-npm- + + - name: Cache node_modules + id: node-modules-cache + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-22-modules-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-22-modules- + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Regenerate baselines + run: npm run test:visual:update + + - name: Verify the fresh baselines pass in this environment + run: npx playwright test --project=visual + + - name: Commit updated baselines + run: | + if git diff --quiet -- tests/visual/baselines.json; then + echo "Baselines unchanged; nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add tests/visual/baselines.json + git commit \ + -m "test(visual): regenerate baselines on ubuntu-latest" \ + -m "Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them." + git push diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 6d39ee99..793dbeee 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -3,11 +3,10 @@ ## Test Infrastructure ### Missing `jsdom` Dependency -- **Status:** Active +- **Status:** Resolved (2026-07-30) - **Detected:** 2026-02-26 -- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project. -- **Impact:** Prevents running unit tests that rely on DOM simulation. -- **Remediation:** `npm install --save-dev jsdom` +- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed. +- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean. ## Run Notes (2026-03-04) @@ -22,19 +21,32 @@ - **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`. - **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`. -## Run Notes (2026-06-20) +## Run Notes (2026-06-20, updated 2026-07-30) -### Additional E2E specs flake under full parallel load (pass in isolation) +### `login-flows.spec.ts:149` — upload/profile buttons stay visible after logout +- **Status:** Active — NOT merely a parallel-load flake +- **Description:** Previously filed as a worker-contention flake that "passes deterministically in isolation." On 2026-07-28 it failed **in isolation** as well (chromium, local and CI `e2e-headless`), while passing in the CI `e2e-tests (e2e)` job — environment-sensitive, not load-sensitive. Direct probes of the same flow (raw `__bitvidTest__.loginWithNsec` → `logout()` against a mock relay) hide the button correctly, so the failure is specific to something in the Playwright fixture path. An attempted fix (running `applyLoggedOutUiState()` before the awaited NWC/profile teardown in `_executeAuthLogout`) did not change the outcome and was reverted rather than shipped unverified. +- **Impact:** One e2e failure in `e2e-headless`; logout hides gated UI correctly in manual testing and direct harness probes. +- **Next step:** Investigate alongside pre-launch TODO #33 ("logout logs out everyone" / NIP-46 session persistence) — same subsystem. + +### `video-upload-publish-lifecycle.spec.ts:49` / `:79` flake under full parallel load - **Status:** Active -- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch). -- **Impact:** Full-suite e2e is flaky; isolated reruns are green. +- **Description:** Under the full `test:e2e` run these intermittently fail on feed-appearance timeouts but pass on retry (CI marks them flaky). Worker resource contention. +- **Impact:** Full-suite e2e occasionally needs retries; isolated reruns are green. ### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode) -- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`) -- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression. -- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing. +- **Status:** Resolved (2026-07-28, `f48150ed`) +- **Description:** The test asserted the **final** `src` equals the CDN URL while simultaneously tolerating "No playable source found" in its status assertion — contradictory, because a failed hosted attempt calls `resetVideoElement()`, which clears `src`. Headless CI cannot decode the archive.org H.264 sample, so the cleared-src path was routine there. +- **Resolution:** The test now records every `src` the player attaches via a MutationObserver and asserts the CDN URL was attached — the actual behavior under test. Green in both `e2e` and `e2e-firefox` CI jobs. ### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels -- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`) -- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`. -- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected. +- **Status:** Not reproducible (2026-07-30) +- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent. +- **Current state:** Passes cleanly (`pass=3 fail=0 cancelled=0`) in isolation and across repeated full-suite sweeps on 2026-07-28–30. Left on file because the original failure was ordering-dependent; if it recurs, capture the full-suite ordering that produced it. + +## Run Notes (2026-07-30) + +### Visual baselines are CI-canonical — local `test:visual` may fail the kitchen-sink diff +- **Status:** By design (since `ab120a03`) +- **Description:** `tests/visual/baselines.json` is regenerated on ubuntu-latest by the `update-visual-baselines` workflow (trigger: `workflow_dispatch` once promoted, or a commit containing `[update-visual-baselines]`). Baselines can only match one environment, and the CI gate is the one that matters — so a dev machine with different Chromium/font rendering may now fail `npm run test:visual` locally (typically the kitchen-sink default theme) even though CI is green. +- **Workaround:** Run visual tests in the pinned container via `scripts/run-playwright-docker.sh`, or rely on CI. Never regenerate baselines from a dev machine. diff --git a/js/app/uiCoordinator.js b/js/app/uiCoordinator.js index dce3daeb..a743ed2f 100644 --- a/js/app/uiCoordinator.js +++ b/js/app/uiCoordinator.js @@ -9,7 +9,6 @@ * Methods use `this` which is bound to the Application instance. */ import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js"; -import { getActiveSigner } from "../nostrClientRegistry.js"; export function createUiCoordinator(deps) { const { @@ -188,52 +187,11 @@ export function createUiCoordinator(deps) { }, syncAuthUiState() { - // Auth-dependent chrome has to follow login/logout/account switches, but - // syncAuthUiState() was only ever called at bootstrap — so everything it - // gates (notably the share menu's "Share on Nostr" item) kept its - // logged-out state for the entire session. Self-install a listener on the - // first run rather than adding a call site in js/app.js, which is at its - // file-size cap. - if ( - !this.__authUiSyncBound && - typeof window !== "undefined" && - typeof window?.addEventListener === "function" - ) { - this.__authUiSyncBound = true; - window.addEventListener("bitvid:auth-changed", () => { - try { - this.syncAuthUiState(); - } catch (error) { - devLogger.warn("[Application] Auth UI resync failed:", error); - } - }); - } - - const loggedIn = this.isUserLoggedIn(); - if (loggedIn) { + if (this.isUserLoggedIn()) { this.applyAuthenticatedUiState(); } else { this.applyLoggedOutUiState(); } - - // Gate the share menu's "Share on Nostr" item. Without this the modal's - // shareNostrAuthState stays at its initial `false` forever and the item - // renders permanently disabled — indistinguishable from plain text. - if (typeof this.videoModal?.setShareNostrAuthState === "function") { - let signer = null; - try { - signer = getActiveSigner(); - } catch (error) { - devLogger.warn("[Application] Failed to read active signer:", error); - } - this.videoModal.setShareNostrAuthState({ - isLoggedIn: loggedIn, - // Mirrors ShareNostrController.handleShare(): a signer that cannot - // sign is no signer at all, and enabling the item would only produce - // an error toast on click. - hasSigner: !!signer && typeof signer.signEvent === "function", - }); - } }, refreshChromeElements() { diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 132ac188..00000000 --- a/netlify.toml +++ /dev/null @@ -1,46 +0,0 @@ -[build] - command = "npm run build && npm run verify:dist:deploy-artifact" - publish = "dist" - -[build.environment] - NODE_ENV = "production" - -[[headers]] - for = "/*" - [headers.values] - Content-Security-Policy = "frame-ancestors *" - -[[headers]] - for = "/index.html" - [headers.values] - Cache-Control = "no-cache, must-revalidate" - -[[headers]] - for = "/embed.html" - [headers.values] - Cache-Control = "no-cache, must-revalidate" - -[[headers]] - for = "/*.html" - [headers.values] - Cache-Control = "no-cache, must-revalidate" - -[[headers]] - for = "/css/*" - [headers.values] - Cache-Control = "public, max-age=31536000, immutable" - -[[headers]] - for = "/js/*" - [headers.values] - Cache-Control = "public, max-age=31536000, immutable" - -[[headers]] - for = "/sw.min.js" - [headers.values] - Cache-Control = "public, max-age=0, must-revalidate" - -[[headers]] - for = "/site.webmanifest" - [headers.values] - Cache-Control = "public, max-age=0, must-revalidate" diff --git a/scripts/build-bitlogin-widget.mjs b/scripts/build-bitlogin-widget.mjs index 8377a4c2..b724ef01 100644 --- a/scripts/build-bitlogin-widget.mjs +++ b/scripts/build-bitlogin-widget.mjs @@ -33,6 +33,43 @@ if (packageJson.name !== "@bitlogin/widget") { throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there)."); } +// Provenance (BitUnlock entropy audit M1, ported). Hashes prove the vendored +// bytes have not changed since they were committed; they cannot say WHICH +// BitLogin commit produced them. @bitlogin/widget is a private package pinned +// at 0.1.0 across many substantive changes, so the version string distinguishes +// nothing. Resolved BEFORE the dist build and before vendorDir is deleted, so +// refusing a dirty checkout leaves the committed vendored files untouched. +const git = (args) => execSync(`git ${args}`, { cwd: bitloginRoot, encoding: "utf8" }).trim(); + +let source; +try { + const dirty = git("status --porcelain") !== ""; + if (dirty && process.env.BITLOGIN_ALLOW_DIRTY !== "1") { + throw new Error( + "DIRTY_CHECKOUT: the BitLogin checkout has uncommitted changes, so the artifact this " + + "would produce corresponds to no commit and cannot be reproduced. Commit there first, " + + "or set BITLOGIN_ALLOW_DIRTY=1 for a throwaway local build.", + ); + } + source = { + repository: git("remote get-url origin"), + commit: git("rev-parse HEAD"), + commitDate: git("log -1 --format=%cI"), + dirty, + provenance: dirty ? "dirty" : "attributed", + toolchain: { node: process.version, vite: packageJson.devDependencies?.vite }, + }; +} catch (error) { + if (error.message.startsWith("DIRTY_CHECKOUT:")) { + throw new Error(error.message.replace("DIRTY_CHECKOUT: ", "")); + } + throw new Error( + `Could not read BitLogin git provenance from ${bitloginRoot}: ${error.message}. ` + + "Vendoring from a non-git source leaves the artifact unattributable; refusing.", + ); +} +console.log(`[vendor-bitlogin] vendoring @bitlogin/widget from ${source.commit.slice(0, 12)}`); + if (!existsSync(widgetDist)) { console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first..."); execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" }); @@ -56,7 +93,7 @@ for (const name of distEntries) { await writeFile( integrityFile, - `${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`, + `${JSON.stringify({ package: packageJson.name, version: packageJson.version, source, files }, null, 2)}\n`, ); try { diff --git a/tests/e2e/coverage-utility-modules.spec.ts b/tests/e2e/coverage-utility-modules.spec.ts index 9ca3081f..4b5658af 100644 --- a/tests/e2e/coverage-utility-modules.spec.ts +++ b/tests/e2e/coverage-utility-modules.spec.ts @@ -13,12 +13,20 @@ test.describe("runtime utility module coverage", () => { "/js/search/searchFilters.js" ); + // 9afbc0cb ("Decode npub authors to hex") made the author filter validate + // its value, because npub-form authors silently matched nothing. The old + // fixture used `author:abc123`, which is neither a decodable npub nor + // 64-char hex, so the filter is now (correctly) rejected and + // authorPubkeys comes back empty. Use a real pubkey to exercise the + // success path, and pin the rejection separately below. + const AUTHOR_HEX = "a".repeat(64); + const parsed = parseFilterQuery( - 'author:abc123 tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm', + `author:${AUTHOR_HEX} tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm`, ); const withErrors = parseFilterQuery( - "duration:oops has:bad unknown:value before:not-a-date", + "duration:oops has:bad unknown:value before:not-a-date author:abc123", ); const serialized = serializeFiltersToQuery(parsed.filters); @@ -30,7 +38,7 @@ test.describe("runtime utility module coverage", () => { }; }); - expect(result.parsed.filters.authorPubkeys).toEqual(["abc123"]); + expect(result.parsed.filters.authorPubkeys).toEqual(["a".repeat(64)]); expect(result.parsed.filters.tags).toEqual(["music", "nostr"]); expect(result.parsed.filters.kind).toBe(30078); expect(result.parsed.filters.relay).toBe("wss://relay.example"); @@ -44,8 +52,13 @@ test.describe("runtime utility module coverage", () => { expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true); expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true); expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true); + // An undecodable author must be reported rather than silently dropped. + expect( + result.withErrors.errors.some((e: any) => /Could not decode author/.test(e.message)), + ).toBe(true); + expect(result.withErrors.filters.authorPubkeys).toEqual([]); - expect(result.serialized).toContain("author:abc123"); + expect(result.serialized).toContain(`author:${"a".repeat(64)}`); expect(result.serialized).toContain("tag:music"); expect(result.serialized).toContain("kind:30078"); expect(result.serialized).toContain("has:magnet"); diff --git a/tests/e2e/nostr-runtime-coverage.spec.ts b/tests/e2e/nostr-runtime-coverage.spec.ts index 41adb6e3..0496de3d 100644 --- a/tests/e2e/nostr-runtime-coverage.spec.ts +++ b/tests/e2e/nostr-runtime-coverage.spec.ts @@ -491,7 +491,21 @@ test.describe("nostr runtime coverage (deterministic)", () => { createdAt: 42, }), ); + // A legacy plaintext entry must be PURGED, not honored. readV1Entry() + // removes it from storage; readStoredSessionActorEntry() then falls back + // to the sole remaining (encrypted) account, so the old `=== null` + // assertion no longer describes the security property -- it just happened + // to hold before 8692dac6 added per-account storage. Assert the property + // directly instead: no plaintext key survives, in the returned entry or + // in raw storage. const migratedLegacyEntry = readStoredSessionActorEntry(); + const legacyRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + let legacyRawPlaintextSurvives = false; + try { + legacyRawPlaintextSurvives = Boolean(legacyRaw && JSON.parse(legacyRaw)?.privateKey); + } catch (error) { + legacyRawPlaintextSurvives = false; + } const actorMatch = isSessionActor({ pubkey: actor, @@ -524,7 +538,10 @@ test.describe("nostr runtime coverage (deterministic)", () => { wrongPassphraseErrorCode, metadataValid: metadata && metadata.salt === encrypted.salt, metadataInvalidIsNull: invalidMetadata === null, - migratedLegacyEntryIsNull: migratedLegacyEntry === null, + migratedLegacyEntryHasPlaintextKey: + typeof (migratedLegacyEntry as any)?.privateKey === "string" && + (migratedLegacyEntry as any).privateKey.length > 0, + legacyRawPlaintextSurvives, actorMatch, actorMismatch, nsecActor, @@ -541,7 +558,8 @@ test.describe("nostr runtime coverage (deterministic)", () => { expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed"); expect(result.metadataValid).toBeTruthy(); expect(result.metadataInvalidIsNull).toBe(true); - expect(result.migratedLegacyEntryIsNull).toBe(true); + expect(result.migratedLegacyEntryHasPlaintextKey).toBe(false); + expect(result.legacyRawPlaintextSurvives).toBe(false); expect(result.actorMatch).toBe(true); expect(result.actorMismatch).toBe(false); expect(result.nsecActor).toBe(false); diff --git a/tests/e2e/ui-modals.spec.ts b/tests/e2e/ui-modals.spec.ts index 3439a309..2e7b60a9 100644 --- a/tests/e2e/ui-modals.spec.ts +++ b/tests/e2e/ui-modals.spec.ts @@ -211,8 +211,16 @@ test.describe("component modal pages", () => { waitUntil: "networkidle", }); - const modal = page.locator(".bv-modal"); - await expect(modal).toHaveCount(1); + // A component page may legitimately ship more than one modal root: + // profile-modal.html also bundles #storageCorsModal, the B2/S3 CORS setup + // helper added in d7ad1516. Asserting exactly one was incidental to what + // this test is actually for -- "the page renders a modal that opens and + // closes" -- so require at least one and drive the primary one, which is + // what the injected script's document.querySelector(".bv-modal") already + // operates on. + const modals = page.locator(".bv-modal"); + expect(await modals.count()).toBeGreaterThan(0); + const modal = modals.first(); // Inject logic to initialize the modal since we are viewing a partial/component in isolation. await page.addScriptTag({ diff --git a/tests/e2e/webseed-playback.spec.ts b/tests/e2e/webseed-playback.spec.ts index ea36c595..ea5721a3 100644 --- a/tests/e2e/webseed-playback.spec.ts +++ b/tests/e2e/webseed-playback.spec.ts @@ -29,6 +29,31 @@ test.describe("Webseed Stream Playback", () => { }); test("plays directly from HTML link (CDN mode)", async ({ page }) => { + // Record every src the player attaches. Asserting the FINAL src is wrong: + // this test already tolerates "No playable source found" in its status + // check (headless CI often cannot decode the archive.org H.264 sample), and + // when the attempt fails resetVideoElement() clears src back to "". The + // behaviour under test is that CDN mode ATTACHES the hosted URL, which is + // observable regardless of whether the remote file then decodes. + await page.evaluate(() => { + (window as any).__attachedSrcs = []; + new MutationObserver((mutations) => { + for (const mutation of mutations) { + const target = mutation.target as HTMLElement; + if (mutation.attributeName === "src" && target.tagName === "VIDEO") { + const value = target.getAttribute("src"); + if (value) { + (window as any).__attachedSrcs.push(value); + } + } + } + }).observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ["src"], + }); + }); + // 1. Trigger playback with just the URL await page.evaluate(async (url) => { const { getApplication } = await import("/js/applicationContext.js"); @@ -55,9 +80,10 @@ test.describe("Webseed Stream Playback", () => { const urlToggle = page.locator('[data-source-toggle="url"]'); await expect(urlToggle).toHaveAttribute("aria-pressed", "true"); - // Check if video is attempting to play from source - const video = page.locator("#modalVideo"); - await expect(video).toHaveAttribute("src", VIDEO_URL); + // Check the player attached the hosted URL (see the observer above). + await expect + .poll(async () => await page.evaluate(() => (window as any).__attachedSrcs)) + .toContain(VIDEO_URL); }); test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => { diff --git a/tests/moderation-service.test.mjs b/tests/moderation-service.test.mjs index a4c0de13..e20f9a74 100644 --- a/tests/moderation-service.test.mjs +++ b/tests/moderation-service.test.mjs @@ -92,18 +92,32 @@ test("trusted report summaries respect personal blocks and admin lists", async ( service.recomputeSummaryForEvent(eventId); + // 18e08bf2 ("refine trust controls") removed `status.whitelisted ||` from + // isTrustedReporter and updated the doc comment from "trustedContacts set (or + // Admin Whitelist)" to "trustedContacts set". Being on the admin whitelist is + // permission to publish, not a statement that your reports carry weight, so a + // whitelisted reporter now counts toward the report TOTAL but not toward + // TRUSTED. + // + // Counted reporters: trusted (t=100), whitelisted (t=400), random (t=300). + // Excluded entirely: blacklisted (admin list) and blocked (personal block). const summary = service.getTrustedReportSummary(eventId); - assert.equal(summary.totalTrusted, 2); - assert.equal(summary.types.nudity.total, 3); - assert.equal(summary.types.nudity.trusted, 2); - assert.equal(summary.types.nudity.latest, 400); + assert.equal(summary.totalTrusted, 1, "only the trusted contact is trusted"); + assert.equal(summary.types.nudity.total, 3, "whitelisted still counts as a report"); + assert.equal(summary.types.nudity.trusted, 1); + assert.equal( + summary.types.nudity.latest, + 400, + "latest still reflects the whitelisted reporter's timestamp" + ); const reporters = service.getTrustedReporters(eventId, "nudity"); assert.deepEqual( reporters.map((entry) => entry.pubkey), - [whitelistedHex, trustedHex] + [trustedHex], + "the whitelisted reporter is no longer surfaced as a trusted reporter" ); - assert.equal(service.trustedReportCount(eventId, "nudity"), 2); + assert.equal(service.trustedReportCount(eventId, "nudity"), 1); }); test("user block updates recompute summaries and emit notifications", async (t) => { diff --git a/tests/nip46-signer-lifecycle.test.mjs b/tests/nip46-signer-lifecycle.test.mjs new file mode 100644 index 00000000..1d287700 --- /dev/null +++ b/tests/nip46-signer-lifecycle.test.mjs @@ -0,0 +1,212 @@ +// SignerManager lifecycle scoping for NIP-46 multi-account (pre-launch TODO #33). +// +// The 2026-06-25 audit blamed "logout logs out everyone" on the single shared +// nip46Client being torn down by any scoped logout. Since then the layers under +// it were fixed piecemeal (per-account v2 session map, scoped clears, targeted +// restore) — but nothing pinned the MANAGER-level contract, so any of those +// fixes could regress silently. These tests exercise the real SignerManager +// against the real signer registry and real (polyfilled) storage. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nip46-logout-scope +// given: "account A active with a live remote-signer client; A and B both have stored sessions; B has a registered signer" +// when: "SignerManager.logout() logs out A" +// then: "A's live client is destroyed exactly once; BOTH stored sessions survive; B's registry signer survives" +// - id: SCN-nip46-restore-failure-scope +// given: "stored sessions for A and B" +// when: "a targeted restore of A fails (locked session / missing session)" +// then: "the failure is typed and NO stored session is deleted" +// observable_outcomes: +// - "fake client's destroy() call count" +// - "readStoredNip46Session(pubkey) after the operation" +// - "resolveActiveSigner(pubkey) for the OTHER account" +// determinism_controls: +// - "in-memory localStorage polyfill; fake in-process client object; no network" +// anti_cheat_rationale: +// prevents: ["over-mocking internal logic", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { SignerManager } from "../js/nostr/managers/SignerManager.js"; +import { + writeStoredNip46SessionSync, + readStoredNip46Session, +} from "../js/nostr/nip46Client.js"; +import { + registerSigner, + setActiveSigner, + clearActiveSigner, + resolveActiveSigner, +} from "../js/nostrClientRegistry.js"; + +const USER_A = + "00000000000000000000000000000000000000000000000000000000000000a1"; +const USER_B = + "00000000000000000000000000000000000000000000000000000000000000b2"; + +function storedSession(userPubkey, remotePubkey) { + return { + version: 1, + clientPublicKey: + "3333333333333333333333333333333333333333333333333333333333333333", + remotePubkey, + relays: ["wss://relay.example"], + encryption: "nip44.v2", + userPubkey, + lastConnectedAt: 1700000000000, + // Sessions at rest carry encrypted secrets; restoring without the + // passphrase must fail typed, not fall through to a broken connect. + encryptedSecrets: "cipher", + keyEncryption: { + salt: "c2FsdA==", + iv: "aXY=", + iterations: 1000, + hash: "SHA-256", + }, + }; +} + +function makeManager() { + // SignerManager only needs the app client for pool access on connect paths; + // the scoping contracts under test never reach the network. + return new SignerManager({ + relays: ["wss://relay.example"], + ensurePool: async () => { + throw new Error("network must not be touched by these tests"); + }, + }); +} + +beforeEach(() => { + localStorage.clear(); + clearActiveSigner(); +}); + +test("logging out the active NIP-46 account keeps every other account intact", () => { + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + // B is a signed-in account in the registry (e.g. logged in earlier this + // session); A is the ACTIVE account with the live remote-signer client. + const signerB = { pubkey: USER_B, signEvent: async (e) => e }; + registerSigner(USER_B, signerB); + + let destroyCalls = 0; + const liveClient = { + remotePubkey: "1".repeat(64), + userPubkey: USER_A, + destroy() { + destroyCalls += 1; + }, + }; + + const manager = makeManager(); + manager.pubkey = USER_A; + manager.nip46Client = liveClient; + + manager.logout(); + + // The departing account's LIVE connection is torn down exactly once… + assert.equal(destroyCalls, 1); + assert.equal(manager.nip46Client, null); + assert.equal(manager.pubkey, null); + + // …but its stored session survives (logout keeps sessions reconnectable — + // that is what makes "switch back later" possible)… + const storedA = readStoredNip46Session(USER_A); + assert.ok(storedA, "A's stored session must survive A's own logout"); + assert.equal(storedA.remotePubkey, "1".repeat(64)); + + // …and account B is completely untouched: stored session AND registry signer. + const storedB = readStoredNip46Session(USER_B); + assert.ok(storedB, "B's stored session must survive A's logout"); + assert.equal(storedB.remotePubkey, "2".repeat(64)); + assert.equal( + resolveActiveSigner(USER_B), + signerB, + "B's registered signer must survive A's logout" + ); +}); + +test("logout is safe when no remote client is live (nsec/nip07 active)", () => { + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + manager.pubkey = USER_A; // active via some non-NIP-46 method + + assert.doesNotThrow(() => manager.logout()); + assert.ok( + readStoredNip46Session(USER_B), + "an unrelated account's NIP-46 session must survive a non-NIP-46 logout" + ); +}); + +test("a failed targeted restore deletes nothing", async () => { + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + + // A's session is passphrase-locked and none was provided: typed failure. + await assert.rejects( + manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }), + (error) => error?.code === "passphrase-required" + ); + + assert.ok(readStoredNip46Session(USER_A), "A's session survives its own failed restore"); + assert.ok(readStoredNip46Session(USER_B), "B's session survives A's failed restore"); +}); + +test("restoring an account with no stored session fails typed and touches nothing", async () => { + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + + await assert.rejects( + manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }), + (error) => error?.code === "no-stored-session" + ); + + assert.ok( + readStoredNip46Session(USER_B), + "an unrelated stored session must survive a missing-session restore attempt" + ); +}); + +test("disconnectRemoteSigner({keepStored:true}) never clears stored sessions", async () => { + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + + const manager = makeManager(); + manager.nip46Client = { destroy() {} }; + + await manager.disconnectRemoteSigner({ keepStored: true }); + + assert.ok(readStoredNip46Session(USER_A)); +}); + +test("disconnectRemoteSigner() without keepStored clears only the last-connected default", async () => { + // The no-arg clear historically wiped the whole v2 map — "after switching + // away you could no longer switch back". Pin the scoped behavior: the v1 + // default slot goes, the per-account map survives. + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + manager.nip46Client = { destroy() {} }; + + await manager.disconnectRemoteSigner(); + + assert.ok( + readStoredNip46Session(USER_A), + "per-account session A must survive a bare disconnect" + ); + assert.ok( + readStoredNip46Session(USER_B), + "per-account session B must survive a bare disconnect" + ); +}); diff --git a/tests/services/playbackService_forcedSource.test.mjs b/tests/services/playbackService_forcedSource.test.mjs index d40a33ee..bf89e02d 100644 --- a/tests/services/playbackService_forcedSource.test.mjs +++ b/tests/services/playbackService_forcedSource.test.mjs @@ -267,8 +267,18 @@ describe("PlaybackService Forced Source Logic", () => { assert.equal(probeUrl.mock.callCount(), 0); }); - test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => { - const probeUrl = mock.fn(async () => ({ outcome: "good" })); + test("Forced Source 'torrent': falls back to the hosted URL when the torrent definitively fails", async () => { + // Spec changed deliberately in playbackService.js: "Even when P2P was + // explicitly forced, a hosted URL is a better outcome than a hard 'No + // playable source found' if the torrent DEFINITIVELY failed." The code + // carries the rollback note (`restore && forcedSource !== "torrent"` to + // make forced P2P hard-fail), so the old assertion here -- source null, + // probe never called -- encodes behavior that was intentionally removed. + // + // It did not merely fail: because forced sources skip the start timeout, + // the now-reachable hosted attempt waited forever on a