diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index 6d93738b..f72827cd 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -221,6 +221,90 @@ test( }, ); +test( + "load defers permission-required decrypts until explicitly enabled", + { concurrency: false }, + async () => { + const pubkey = "e".repeat(64); + + const relayUrls = ["wss://relay-permissions.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + const originalFetchIncremental = nostrClient.fetchListIncrementally; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalWindowNostr = window.nostr; + + const event = { + id: "pref-permission", + created_at: 400, + pubkey, + content: "cipher-permission", + tags: [["encrypted", "nip04"]], + }; + + let fetchCalls = 0; + nostrClient.fetchListIncrementally = async () => { + fetchCalls += 1; + return fetchCalls === 1 ? [event] : []; + }; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + const decryptCalls = []; + window.nostr = { + nip04: { + decrypt: async () => { + decryptCalls.push("nip04"); + return JSON.stringify({ + version: 1, + interests: ["Late"], + disinterests: [], + }); + }, + }, + }; + + clearActiveSigner(); + + try { + await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false }); + + assert.equal( + decryptCalls.length, + 0, + "decryption should be skipped when permissions are deferred", + ); + assert.equal( + hashtagPreferences.lastLoadError?.code, + "hashtag-preferences-permission-required", + "permission-required errors should be captured for deferred prompts", + ); + assert.deepEqual(hashtagPreferences.getInterests(), []); + + await hashtagPreferences.load(pubkey, { allowPermissionPrompt: true }); + + assert.equal( + decryptCalls.length, + 1, + "explicit permission prompts should retry decryption", + ); + assert.deepEqual(hashtagPreferences.getInterests(), ["late"]); + } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false }); + nostrClient.fetchListIncrementally = originalFetchIncremental; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + window.nostr = originalWindowNostr; + } + }, +); + test( "interest and disinterest lists remain exclusive", { concurrency: false }, diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index 576facb2..ffdf57c3 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -456,6 +456,42 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () = } }); +test("NIP-07 login does not wait for deferred permission grants", async () => { + clearStoredPermissions(); + const env = setupLoginEnvironment(); + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + let completionPromise; + + nostrClient.ensureExtensionPermissions = async () => { + completionPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("permission denied")), 300); + }); + return { ok: true, completionPromise }; + }; + + try { + const start = Date.now(); + const result = await nip07Provider.login({ nostrClient }); + const duration = Date.now() - start; + assert.equal(result.pubkey, HEX_PUBKEY); + assert.ok( + duration < 200, + `login should resolve before deferred permission grants (duration: ${duration}ms)`, + ); + assert.ok(completionPromise, "completionPromise should be captured"); + await assert.rejects( + completionPromise, + /permission denied/, + "deferred permission grants should still reject", + ); + } finally { + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + env.restore(); + nostrClient.logout(); + clearStoredPermissions(); + } +}); + test("NIP-07 login surfaces enable permission errors", async () => { clearStoredPermissions(); const env = setupLoginEnvironment({ diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index d325c089..7d81ba8c 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -541,6 +541,114 @@ test( }, ); +test( + "loadSubscriptions retries permission-required decrypts only when enabled", + async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + const originalFetchIncremental = nostrClient.fetchListIncrementally; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + const originalEnsureSubscription = manager.ensureSubscriptionListSubscription; + + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) { + globalThis.window = {}; + } + const originalWindowNostr = globalThis.window.nostr; + + const relayUrls = ["wss://relay-permission.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + const actor = "f".repeat(64); + const event = { + id: "event-permission", + created_at: 100, + pubkey: actor, + content: "cipher-permission", + tags: [["encrypted", "nip04"]], + }; + + nostrClient.pool = {}; + nostrClient.fetchListIncrementally = async () => [event]; + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + manager.ensureSubscriptionListSubscription = () => null; + clearActiveSigner(); + + const decryptCalls = []; + manager.decryptSubscriptionEvent = async (_event, _pubkey, options) => { + decryptCalls.push(options); + if (!options?.allowPermissionPrompt) { + const error = new Error("permissions required"); + error.code = "subscriptions-permission-required"; + return { ok: false, error }; + } + return { ok: true, plaintext: JSON.stringify([["p", "pub-allowed"]]) }; + }; + + try { + await manager.updateFromRelays(actor, { allowPermissionPrompt: false }); + + assert.equal( + decryptCalls.length, + 1, + "should attempt decryption once without retry", + ); + assert.equal( + manager.subscribedPubkeys.size, + 0, + "subscriptions should remain empty when permissions are deferred", + ); + + await manager.updateFromRelays(actor, { allowPermissionPrompt: true }); + + assert.equal( + decryptCalls.length, + 2, + "explicit permission prompts should trigger a retry", + ); + assert.deepEqual( + Array.from(manager.subscribedPubkeys), + ["pub-allowed"], + "retry with permission prompts should populate subscriptions", + ); + } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + nostrClient.fetchListIncrementally = originalFetchIncremental; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + manager.ensureSubscriptionListSubscription = originalEnsureSubscription; + setActiveSigner(originalSigner); + if (typeof originalWindowNostr === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = originalWindowNostr; + } + if (!hadWindow) { + delete globalThis.window; + } + localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); + } + }, +); + test( "showSubscriptionVideos waits for nostrService warm-up and refreshes after updates", async () => { diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 3fde4000..e6a1f43e 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -695,6 +695,102 @@ await (async () => { } })(); +await (async () => { + const actor = "9".repeat(64); + const relays = ["wss://relay-login-mode.example"]; + + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + const originalNostr = window.nostr; + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; + const originalLoaded = userBlocks.loaded; + + let permissionRequests = 0; + nostrClient.ensureExtensionPermissions = async () => { + permissionRequests += 1; + return { ok: true }; + }; + + nostrClient.relays = relays; + nostrClient.writeRelays = relays; + nostrClient.pool = { + list: async () => [ + { + id: "event-login", + kind: 10000, + created_at: 100, + pubkey: actor, + content: "cipher-login", + tags: [["encrypted", "nip04"]], + }, + ], + }; + + const statusEvents = []; + const unsubscribeStatus = userBlocks.on(USER_BLOCK_EVENTS.STATUS, (detail) => { + statusEvents.push(detail); + }); + + clearActiveSigner(); + window.nostr = undefined; + + try { + const start = Date.now(); + await userBlocks.loadBlocks(actor, { allowPermissionPrompt: false }); + const duration = Date.now() - start; + + assert.equal( + permissionRequests, + 0, + "login-mode load should not request extension permissions", + ); + assert.ok( + duration < 200, + `login-mode load should settle quickly (duration: ${duration}ms)`, + ); + + const permissionStatus = statusEvents.find( + (detail) => detail?.status === "permission-required", + ); + assert.ok(permissionStatus, "login-mode load should emit permission-required status"); + assert.equal(permissionStatus.allowPermissionPrompt, false); + } finally { + if (typeof unsubscribeStatus === "function") { + unsubscribeStatus(); + } + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; + userBlocks.loaded = originalLoaded; + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + window.nostr = originalNostr; + clearActiveSigner(); + if (originalSigner) { + setActiveSigner(originalSigner); + } + } +})(); + await (async () => { const actor = "1".repeat(64); const blocked = "2".repeat(64);