diff --git a/docs/testing/playwright-function-coverage.md b/docs/testing/playwright-function-coverage.md index a1f02786..d410a770 100644 --- a/docs/testing/playwright-function-coverage.md +++ b/docs/testing/playwright-function-coverage.md @@ -37,11 +37,11 @@ This command: From a full real run (`npm run test:e2e:coverage`) on 2026-02-22: -- E2E result: `38 passed` -- Function coverage: `29.41%` (`2826/9609`) +- E2E result: `40 passed` +- Function coverage: `29.9%` (`2863/9576`) - Loaded source files: `297/313` -- Console logs: `68` -- Console error-like entries: `6` +- Console logs: `72` +- Console error-like entries: `4` - Page errors: `0` Primary issue groups captured: @@ -56,7 +56,8 @@ Recent high-impact coverage improvements: - `js/nostr/commentEvents.js`: `90.91%` (`20/20`) - `js/nostr/reactionEvents.js`: `78.57%` (`11/14`) -- `js/nostr/publishHelpers.js`: `26.47%` (`9/34`) +- `js/nostr/publishHelpers.js`: `79.41%` (`27/34`) +- `js/nostr/sessionActor.js`: `79.41%` (`27/34`) ## Integration Rules For Agents diff --git a/tests/e2e/nostr-runtime-coverage.spec.ts b/tests/e2e/nostr-runtime-coverage.spec.ts index 9c544bfe..41adb6e3 100644 --- a/tests/e2e/nostr-runtime-coverage.spec.ts +++ b/tests/e2e/nostr-runtime-coverage.spec.ts @@ -425,4 +425,356 @@ test.describe("nostr runtime coverage (deterministic)", () => { expect(result.fallbackOk).toBe(true); expect(result.fallbackSignCalls).toBe(1); }); + + test("exercises session-actor crypto + storage lifecycle", async ({ page }) => { + const result = await page.evaluate(async ({ actor }) => { + const sessionActor = await import("/js/nostr/sessionActor.js"); + const { + SESSION_ACTOR_STORAGE_KEY, + encryptSessionPrivateKey, + decryptSessionPrivateKey, + normalizeStoredEncryptionMetadata, + readStoredSessionActorEntry, + persistSessionActor, + clearStoredSessionActor, + isSessionActor, + __testExports, + } = sessionActor; + + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + + const passphrase = "correct horse battery staple"; + const privateKey = "f".repeat(64); + const encrypted = await encryptSessionPrivateKey(privateKey, passphrase); + + const encryptedActor = { + pubkey: actor, + privateKeyEncrypted: encrypted.ciphertext, + encryption: { + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + algorithm: encrypted.algorithm, + version: encrypted.version, + }, + createdAt: 12345, + }; + + persistSessionActor(encryptedActor); + const persistedRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + const storedEntry = readStoredSessionActorEntry(); + const decrypted = await decryptSessionPrivateKey(storedEntry, passphrase); + + let wrongPassphraseErrorCode = ""; + try { + await decryptSessionPrivateKey(storedEntry, "wrong passphrase"); + } catch (error) { + wrongPassphraseErrorCode = error?.code || ""; + } + + const metadata = normalizeStoredEncryptionMetadata({ + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + algorithm: encrypted.algorithm, + version: encrypted.version, + }); + const invalidMetadata = normalizeStoredEncryptionMetadata({ salt: "", iv: "" }); + + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify({ + pubkey: actor, + privateKey: "legacy-plaintext-key", + createdAt: 42, + }), + ); + const migratedLegacyEntry = readStoredSessionActorEntry(); + + const actorMatch = isSessionActor({ + pubkey: actor, + sessionActor: { pubkey: actor, source: "nip46" }, + }); + const actorMismatch = isSessionActor({ + pubkey: actor, + sessionActor: { pubkey: "0".repeat(64), source: "nip46" }, + }); + const nsecActor = isSessionActor({ + pubkey: actor, + sessionActor: { pubkey: actor, source: "nsec" }, + }); + + const randomBytes = __testExports.generateRandomBytes(8); + const base64 = __testExports.arrayBufferToBase64(randomBytes); + const roundtripBytes = __testExports.base64ToUint8Array(base64); + const subtleAvailable = __testExports.isSubtleCryptoAvailable(); + + clearStoredSessionActor(); + const clearedRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + await __testExports._closeSessionActorDb(); + + return { + persistedRawExists: typeof persistedRaw === "string" && persistedRaw.length > 0, + storedEntryHasCiphertext: + typeof storedEntry?.privateKeyEncrypted === "string" && + storedEntry.privateKeyEncrypted.length > 0, + decryptedMatches: decrypted === privateKey, + wrongPassphraseErrorCode, + metadataValid: metadata && metadata.salt === encrypted.salt, + metadataInvalidIsNull: invalidMetadata === null, + migratedLegacyEntryIsNull: migratedLegacyEntry === null, + actorMatch, + actorMismatch, + nsecActor, + randomBytesLength: randomBytes.length, + roundtripBytesLength: roundtripBytes?.length || 0, + subtleAvailable, + clearedRaw, + }; + }, { actor: HEX_B }); + + expect(result.persistedRawExists).toBe(true); + expect(result.storedEntryHasCiphertext).toBe(true); + expect(result.decryptedMatches).toBe(true); + expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed"); + expect(result.metadataValid).toBeTruthy(); + expect(result.metadataInvalidIsNull).toBe(true); + expect(result.migratedLegacyEntryIsNull).toBe(true); + expect(result.actorMatch).toBe(true); + expect(result.actorMismatch).toBe(false); + expect(result.nsecActor).toBe(false); + expect(result.randomBytesLength).toBe(8); + expect(result.roundtripBytesLength).toBe(8); + expect(result.subtleAvailable).toBe(true); + expect(result.clearedRaw).toBeNull(); + }); + + test("exercises publish helper repost/mirror/rebroadcast/revert flows", async ({ + page, + }) => { + const result = await page.evaluate(async ({ actorA, actorB }) => { + const { + repostEvent, + mirrorVideoEvent, + rebroadcastEvent, + buildRevertVideoPayload, + } = await import("/js/nostr/publishHelpers.js"); + + const relayPublish = "wss://relay.publish"; + const relaySource = "wss://relay.source"; + const eventId = "1".repeat(64); + const dTag = "video-root-1"; + const author = actorB; + + const rawEvent = { + id: eventId, + pubkey: author, + kind: 30078, + created_at: 10, + tags: [["d", dTag], ["title", "Test Video"]], + content: JSON.stringify({ + version: 3, + title: "Test Video", + url: "https://example.com/video.mp4", + videoRootId: dTag, + mode: "live", + }), + relay: relaySource, + }; + + const videoEvent = { + id: eventId, + pubkey: author, + kind: 30078, + title: "Test Video", + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:" + "f".repeat(40), + thumbnail: "https://example.com/thumb.jpg", + description: "desc", + videoRootId: dTag, + pointerInfo: { eventRelay: relaySource }, + }; + + const publishCalls = []; + const client = { + pubkey: actorA, + relays: [relayPublish], + writeRelays: [relayPublish], + allEvents: new Map([[eventId, videoEvent]]), + rawEvents: new Map([[eventId, rawEvent]]), + ensurePool: async () => client.pool, + ensureSessionActor: async () => actorA, + fetchRawEventById: async () => rawEvent, + countEventsAcrossRelays: async () => ({ total: 0 }), + pool: { + publish(relays, event) { + publishCalls.push({ relays, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + Promise.resolve().then(() => handler()); + } + }, + }; + }, + }, + }; + + const signer = { + signEvent: async (event) => ({ + ...event, + id: `${event.kind}-signed`, + sig: `${event.kind}-sig`, + }), + }; + + const repostOk = await repostEvent({ + client, + eventId, + options: {}, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: (event) => ({ + ...event, + id: "fallback-repost", + sig: "fallback-repost-sig", + }), + eventToAddressPointer: () => `30078:${author}:${dTag}`, + }); + + const repostInvalid = await repostEvent({ + client, + eventId: "", + options: {}, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: (event) => event, + eventToAddressPointer: () => "", + }); + + const mirrorInvalidSha = await mirrorVideoEvent({ + client, + eventId, + options: { + fileSha256: "zz-invalid-sha", + }, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: (event) => event, + inferMimeTypeFromUrl: () => "video/mp4", + }); + + const mirrorOk = await mirrorVideoEvent({ + client, + eventId, + options: { + url: "https://example.com/video.mp4", + fileSha256: "a".repeat(64), + originalFileSha256: "b".repeat(64), + additionalTags: [["client", "playwright"]], + }, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: (event) => ({ + ...event, + id: "mirror-fallback", + sig: "mirror-fallback-sig", + }), + inferMimeTypeFromUrl: () => "video/mp4", + }); + + const rebroadcastOk = await rebroadcastEvent({ + client, + eventId, + options: { + relays: [relayPublish], + rawEvent, + }, + }); + + const rebroadcastCooldown = await rebroadcastEvent({ + client, + eventId, + options: { + relays: [relayPublish], + rawEvent, + }, + }); + + const clientCountPresent = { + ...client, + countEventsAcrossRelays: async () => ({ total: 2 }), + }; + const rebroadcastAlreadyPresent = await rebroadcastEvent({ + client: clientCountPresent, + eventId: "2".repeat(64), + options: { + relays: [relayPublish], + rawEvent: { ...rawEvent, id: "2".repeat(64) }, + }, + }); + + const clientNotFound = { + ...client, + rawEvents: new Map(), + fetchRawEventById: async () => null, + }; + const rebroadcastMissing = await rebroadcastEvent({ + client: clientNotFound, + eventId: "3".repeat(64), + options: { + relays: [relayPublish], + }, + }); + + const revertPayload = buildRevertVideoPayload({ + baseEvent: rawEvent, + originalEventId: eventId, + pubkey: actorA, + existingD: dTag, + stableDTag: dTag, + }); + + return { + repostOk: repostOk.ok, + repostKind: repostOk.event?.kind, + repostInvalidError: repostInvalid.error, + mirrorInvalidShaError: mirrorInvalidSha.error, + mirrorOk: mirrorOk.ok, + mirrorKind: mirrorOk.event?.kind, + rebroadcastOk: rebroadcastOk.ok, + rebroadcastHasSummary: Array.isArray(rebroadcastOk?.summary?.accepted), + rebroadcastCooldownError: rebroadcastCooldown.error, + rebroadcastAlreadyPresent: rebroadcastAlreadyPresent.alreadyPresent === true, + rebroadcastMissingError: rebroadcastMissing.error, + revertDeleted: Boolean( + (() => { + try { + const parsed = JSON.parse(revertPayload.content || "{}"); + return parsed.deleted === true; + } catch { + return false; + } + })(), + ), + publishCalls: publishCalls.length, + }; + }, { actorA: HEX_A, actorB: HEX_B }); + + expect(result.repostOk).toBe(true); + expect(result.repostKind).toBeGreaterThan(0); + expect(result.repostInvalidError).toBe("invalid-event-id"); + expect(result.mirrorInvalidShaError).toBe("invalid-file-sha"); + expect(result.mirrorOk).toBe(true); + expect(result.mirrorKind).toBeGreaterThan(0); + expect(result.rebroadcastOk).toBe(true); + expect(result.rebroadcastHasSummary).toBe(true); + expect(result.rebroadcastCooldownError).toBe("cooldown-active"); + expect(result.rebroadcastAlreadyPresent).toBe(true); + expect(result.rebroadcastMissingError).toBe("event-not-found"); + expect(result.revertDeleted).toBe(true); + expect(result.publishCalls).toBeGreaterThan(0); + }); });