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/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/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