From 98e63e29e6b8757e62e8db2b74c12014098c84c4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:07:17 +0000 Subject: [PATCH] Expand tests for generic connection payloads and R2-specific mapping This change adds comprehensive unit and integration tests to cover: 1. `signerKeyManager.js`: Verification of key derivation from signatures and passphrases. 2. `storageService.js`: Tests for saving, retrieving, and decrypting connections, specifically verifying that generic S3 payloads (endpoint, region, bucket) are preserved alongside R2 payloads. 3. `UploadModal.js`: Integration tests mocking the storage service to verify that R2 connection details correctly map to the UI input fields and that the "Unlock" flow updates the UI state. It also introduces `fake-indexeddb` as a dev dependency to enable reliable testing of IndexedDB interactions in the Node/JSDOM test environment. --- package-lock.json | 11 ++ package.json | 1 + tests/ui/signerKeyManager.test.mjs | 97 +++++++++++++ tests/ui/storageService.test.mjs | 167 ++++++++++++++++++++++ tests/ui/uploadModal-integration.test.mjs | 164 +++++++++++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 tests/ui/signerKeyManager.test.mjs create mode 100644 tests/ui/storageService.test.mjs create mode 100644 tests/ui/uploadModal-integration.test.mjs diff --git a/package-lock.json b/package-lock.json index a57edbf2..25385c75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "autoprefixer": "^10.4.19", "cross-env": "^7.0.3", "esbuild": "^0.25.10", + "fake-indexeddb": "^6.2.5", "jsdom": "^24.1.3", "nostr-tools": "^2.19.4", "pixelmatch": "^5.3.0", @@ -2385,6 +2386,16 @@ "bare-events": "^2.7.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-blob-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/fast-blob-stream/-/fast-blob-stream-1.1.1.tgz", diff --git a/package.json b/package.json index 702b94d4..87a63c2f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "autoprefixer": "^10.4.19", "cross-env": "^7.0.3", "esbuild": "^0.25.10", + "fake-indexeddb": "^6.2.5", "jsdom": "^24.1.3", "nostr-tools": "^2.19.4", "pixelmatch": "^5.3.0", diff --git a/tests/ui/signerKeyManager.test.mjs b/tests/ui/signerKeyManager.test.mjs new file mode 100644 index 00000000..404adfbc --- /dev/null +++ b/tests/ui/signerKeyManager.test.mjs @@ -0,0 +1,97 @@ +import { test, describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { + deriveKeyFromSignature, + deriveKeyFromPassphrase, + generatePassphraseSalt, +} from "../../js/services/signerKeyManager.js"; +import { createUiDom } from "./helpers/jsdom-test-helpers.mjs"; + +describe("SignerKeyManager", () => { + let dom; + + before(async () => { + // We need a DOM environment because the code might rely on global crypto or other browser APIs + // Although Node 19+ has global crypto, the code might expect window.crypto. + // Let's check if the module uses `crypto.subtle` or `window.crypto.subtle`. + // The code imports `bytesToHex` but uses global `crypto`. + // The `signerKeyManager.js` uses `crypto.subtle`. + // Node.js has global `crypto` since v19, but let's ensure consistency with JSDOM just in case. + dom = createUiDom(); + + // Polyfill crypto if JSDOM doesn't fully support subtle crypto (it usually doesn't). + // Node.js has a native Web Crypto implementation at globalThis.crypto. + // If JSDOM overwrites it or if code expects it on window, we map it. + if (!globalThis.crypto) { + // Should exist in Node environment, but let's be safe + const cryptoModule = await import("node:crypto"); + globalThis.crypto = cryptoModule.webcrypto; + } + }); + + after(() => { + if (dom) dom.cleanup(); + }); + + describe("deriveKeyFromSignature", () => { + it("should derive an AES-GCM key from a hex signature", async () => { + // Mock signature (64 bytes hex) + const mockSignature = "a".repeat(128); + + const key = await deriveKeyFromSignature(mockSignature); + + assert.ok(key, "Key should be generated"); + assert.strictEqual(key.algorithm.name, "AES-GCM", "Algorithm should be AES-GCM"); + assert.strictEqual(key.extractable, false, "Key should not be extractable"); + assert.deepStrictEqual(key.usages, ["encrypt", "decrypt"], "Key should have encrypt/decrypt usages"); + }); + + it("should throw error for invalid signature", async () => { + await assert.rejects( + async () => await deriveKeyFromSignature(null), + /Invalid signature/, + "Should reject null signature" + ); + }); + }); + + describe("deriveKeyFromPassphrase", () => { + it("should derive an AES-GCM key from passphrase and salt", async () => { + const passphrase = "correct horse battery staple"; + const salt = generatePassphraseSalt(); + + const key = await deriveKeyFromPassphrase(passphrase, salt); + + assert.ok(key, "Key should be generated"); + assert.strictEqual(key.algorithm.name, "AES-GCM", "Algorithm should be AES-GCM"); + assert.strictEqual(key.extractable, false, "Key should not be extractable"); + }); + + it("should generate different keys for different salts", async () => { + const passphrase = "password123"; + const salt1 = generatePassphraseSalt(); + const salt2 = generatePassphraseSalt(); + + // We can't export the keys to compare bits because they are non-extractable. + // However, we can test that they encrypt the same data to different ciphertexts (due to key difference). + // Actually, AES-GCM uses a random IV usually, so ciphertexts differ anyway. + // But fundamentally, if the function returns successfully for different salts, that's the main test here without export access. + + const key1 = await deriveKeyFromPassphrase(passphrase, salt1); + const key2 = await deriveKeyFromPassphrase(passphrase, salt2); + + assert.notDeepStrictEqual(salt1, salt2, "Salts should be random"); + assert.ok(key1, "Key1 generated"); + assert.ok(key2, "Key2 generated"); + }); + + it("should throw error if salt is invalid", async () => { + const passphrase = "pw"; + await assert.rejects( + async () => await deriveKeyFromPassphrase(passphrase, "bad-salt"), + /Invalid salt/, + "Should reject string salt (expects Uint8Array)" + ); + }); + }); +}); diff --git a/tests/ui/storageService.test.mjs b/tests/ui/storageService.test.mjs new file mode 100644 index 00000000..9ecb00b0 --- /dev/null +++ b/tests/ui/storageService.test.mjs @@ -0,0 +1,167 @@ +import { test, describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { createUiDom } from "./helpers/jsdom-test-helpers.mjs"; + +// We need to import storageService AFTER we setup globals like indexedDB and crypto +// So we will use dynamic import in the before() hook. +let storageService; +let PROVIDERS; + +describe("StorageService", () => { + let dom; + const mockPubkey = "0000000000000000000000000000000000000000000000000000000000000001"; + + // Mock Signer (NIP-04 style for simplicity) + // In a real app, this does ECIES. Here we just reverse string or something reversible for test speed/simplicity + // OR strictly obey the contract: returns Promise + const mockSigner = { + encrypt: async (pubkey, plaintext) => { + // Fake encryption: prefix "enc:" + hex(plaintext) + return "enc:" + Buffer.from(plaintext).toString("hex"); + }, + decrypt: async (pubkey, ciphertext) => { + // Fake decryption + if (!ciphertext.startsWith("enc:")) throw new Error("Bad ciphertext"); + const hex = ciphertext.slice(4); + return Buffer.from(hex, "hex").toString("utf8"); + }, + // Add NIP-04 aliases just in case + nip04Encrypt: async (pk, pt) => "enc:" + Buffer.from(pt).toString("hex"), + nip04Decrypt: async (pk, ct) => { + if (!ct.startsWith("enc:")) throw new Error("Bad ciphertext"); + return Buffer.from(ct.slice(4), "hex").toString("utf8"); + } + }; + + before(async () => { + dom = createUiDom(); + + // Polyfill Globals + global.window = dom.window; + + // Use fake-indexeddb for reliable testing + const { indexedDB, IDBKeyRange } = await import("fake-indexeddb"); + global.indexedDB = indexedDB; + globalThis.indexedDB = indexedDB; + global.IDBKeyRange = IDBKeyRange; + globalThis.IDBKeyRange = IDBKeyRange; + + // Ensure crypto is available (Node's or Window's) + if (!global.crypto) { + const cryptoModule = await import("node:crypto"); + global.crypto = global.window.crypto || cryptoModule.webcrypto; + } + // StorageService uses crypto.subtle + if (!global.crypto.subtle) { + // If JSDOM crypto is partial, fallback to Node + const cryptoModule = await import("node:crypto"); + global.crypto = cryptoModule.webcrypto; + } + + // Now import the service (it instantiates 'storageService' singleton on load) + const module = await import("../../js/services/storageService.js"); + storageService = module.default; + PROVIDERS = module.PROVIDERS; + }); + + after(() => { + if (dom) dom.cleanup(); + }); + + // Since storageService is a singleton, state might persist. + // We should try to reset it or use different pubkeys. + // The service uses IndexedDB. We can try to delete the DB between tests if needed, + // or just rely on unique pubkeys or connection IDs. + // For unit tests, clearing the DB is cleaner. + beforeEach(async () => { + // Optional: Clear DB store if possible. + // Since `storageService` doesn't expose a clear method, we'll just use unique IDs/pubkeys or rely on overwrites. + // But we can reset the in-memory masterKeys map. + if (storageService.masterKeys) storageService.masterKeys.clear(); + }); + + it("should unlock storage with a valid signer", async () => { + assert.strictEqual(storageService.isUnlocked(mockPubkey), false, "Should be locked initially"); + + await storageService.unlock(mockPubkey, { signer: mockSigner }); + + assert.strictEqual(storageService.isUnlocked(mockPubkey), true, "Should be unlocked"); + }); + + it("should save and retrieve an R2 connection", async () => { + // Ensure unlocked + if (!storageService.isUnlocked(mockPubkey)) { + await storageService.unlock(mockPubkey, { signer: mockSigner }); + } + + const connectionId = "r2-test-1"; + const payload = { + provider: PROVIDERS.R2, + accountId: "acc123", + accessKeyId: "key123", + secretAccessKey: "secret123" + }; + const meta = { + label: "My R2", + baseDomain: "https://pub.r2.dev" + }; + + await storageService.saveConnection(mockPubkey, connectionId, payload, meta); + + const retrieved = await storageService.getConnection(mockPubkey, connectionId); + + assert.ok(retrieved, "Should return connection"); + assert.strictEqual(retrieved.provider, PROVIDERS.R2); + assert.strictEqual(retrieved.accountId, "acc123"); + assert.strictEqual(retrieved.accessKeyId, "key123"); + assert.strictEqual(retrieved.meta.label, "My R2"); + assert.strictEqual(retrieved.meta.baseDomain, "https://pub.r2.dev"); + + // Ensure secret didn't leak into meta or something unintended, but main point is it came back + assert.strictEqual(retrieved.secretAccessKey, "secret123"); + }); + + it("should save and retrieve a Generic S3 connection with extra fields", async () => { + // Verify Generic S3 Payload (endpoint, region, bucket) + if (!storageService.isUnlocked(mockPubkey)) { + await storageService.unlock(mockPubkey, { signer: mockSigner }); + } + + const connectionId = "s3-generic-1"; + const payload = { + provider: PROVIDERS.GENERIC || "generic_s3", + endpoint: "https://minio.local:9000", + region: "us-east-1", + bucket: "my-bucket", + accessKeyId: "minio-key", + secretAccessKey: "minio-secret" + }; + const meta = { + label: "Local Minio", + defaultForUploads: true + }; + + await storageService.saveConnection(mockPubkey, connectionId, payload, meta); + + const retrieved = await storageService.getConnection(mockPubkey, connectionId); + + assert.ok(retrieved, "Should return connection"); + assert.strictEqual(retrieved.provider, "generic_s3"); + assert.strictEqual(retrieved.endpoint, "https://minio.local:9000", "Endpoint should be preserved"); + assert.strictEqual(retrieved.region, "us-east-1", "Region should be preserved"); + assert.strictEqual(retrieved.bucket, "my-bucket", "Bucket should be preserved"); + assert.strictEqual(retrieved.accessKeyId, "minio-key"); + assert.strictEqual(retrieved.meta.defaultForUploads, true); + }); + + it("should fail to get connection if locked", async () => { + storageService.lock(mockPubkey); + assert.strictEqual(storageService.isUnlocked(mockPubkey), false); + + await assert.rejects( + async () => await storageService.getConnection(mockPubkey, "r2-test-1"), + /Storage is locked/, + "Should throw if locked" + ); + }); +}); diff --git a/tests/ui/uploadModal-integration.test.mjs b/tests/ui/uploadModal-integration.test.mjs new file mode 100644 index 00000000..63f8bf26 --- /dev/null +++ b/tests/ui/uploadModal-integration.test.mjs @@ -0,0 +1,164 @@ +import { test, describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { createUiDom } from "./helpers/jsdom-test-helpers.mjs"; + +describe("UploadModal Integration", () => { + let dom; + let UploadModal; + + // Mock StorageService + const mockStorageService = { + isUnlocked: () => false, + unlock: async () => {}, + listConnections: async () => [], + getConnection: async () => null, + saveConnection: async () => {}, + isMock: true + }; + + before(async () => { + dom = createUiDom(); + global.window = dom.window; + global.document = dom.document; + global.EventTarget = dom.window.EventTarget; + + // UploadModal uses HTML elements + global.HTMLElement = dom.window.HTMLElement; + global.HTMLInputElement = dom.window.HTMLInputElement; + global.HTMLButtonElement = dom.window.HTMLButtonElement; + global.HTMLDivElement = dom.window.HTMLDivElement; + + // Load UploadModal class + const module = await import("../../js/ui/components/UploadModal.js"); + UploadModal = module.UploadModal; + }); + + after(() => { + if (dom) dom.cleanup(); + }); + + it("should auto-fill default R2 connection when loaded and unlocked", async () => { + // Setup Mock Data + const pubkey = "pk1"; + const r2Conn = { + id: "r2-def", + provider: "cloudflare_r2", + meta: { label: "My R2", defaultForUploads: true, baseDomain: "https://r2.pub" }, + accountId: "acc-id", + accessKeyId: "acc-key", + secretAccessKey: "acc-secret" + }; + + // Configure Mock + mockStorageService.isUnlocked = (pk) => pk === pubkey; + mockStorageService.listConnections = async (pk) => [r2Conn]; + mockStorageService.getConnection = async (pk, id) => (id === r2Conn.id ? r2Conn : null); + + // Setup DOM + const container = document.createElement("div"); + container.id = "modalContainer"; + document.body.appendChild(container); + + const modal = new UploadModal({ + storageService: mockStorageService, + getCurrentPubkey: () => pubkey, + removeTrackingScripts: () => {}, + setGlobalModalState: () => {}, + }); + + // Override load to use our container directly or mocked fetch + // We must mock global.fetch to return the HTML content since JSDOM doesn't access file system. + + const mockHtml = ` + + `; + + global.fetch = async (url) => { + if (url.includes("upload-modal.html")) { + return { + ok: true, + text: async () => mockHtml + }; + } + return { ok: false }; + }; + + await modal.load({ container }); + + // Verify Inputs populated + const accInput = document.getElementById("input-r2-account"); + const keyInput = document.getElementById("input-r2-key"); + const secretInput = document.getElementById("input-r2-secret"); + + assert.strictEqual(accInput.value, "acc-id", "Account ID should be populated"); + assert.strictEqual(keyInput.value, "acc-key", "Access Key should be populated"); + assert.strictEqual(secretInput.value, "acc-secret", "Secret should be populated"); + }); + + it("should handle locked state and unlock flow", async () => { + const pubkey = "pk2"; + let unlocked = false; + + // Mock StorageService Behavior + mockStorageService.isUnlocked = (pk) => unlocked; + mockStorageService.unlock = async (pk) => { unlocked = true; }; + mockStorageService.listConnections = async () => []; // Return empty first to verify logic runs without crashing + + // Setup DOM + const container = document.createElement("div"); + container.id = "modalContainer2"; // Different ID to avoid collision if DOM persists + document.body.appendChild(container); + + // Re-instantiate + const modal = new UploadModal({ + storageService: mockStorageService, + getCurrentPubkey: () => pubkey, + authService: { signer: {} }, // Mock signer presence + setGlobalModalState: () => {}, + }); + + await modal.load({ container }); + + const unlockBtn = container.querySelector("#btn-storage-unlock"); + const lockStatus = container.querySelector("#storage-lock-status"); + + // 1. Check Locked State + assert.strictEqual(unlocked, false); + assert.ok(!unlockBtn.classList.contains("hidden"), "Unlock button should be visible"); + assert.match(lockStatus.textContent, /Locked/, "Status should say Locked"); + + // 2. Perform Unlock + // We need to simulate click or call handler. + // modal.handleUnlock is bound to click. + unlockBtn.click(); + + // Wait for async operations (microtask queue) + await new Promise(r => setTimeout(r, 10)); + + assert.strictEqual(unlocked, true, "Storage should be unlocked"); + assert.ok(unlockBtn.classList.contains("hidden"), "Unlock button should be hidden after unlock"); + assert.match(lockStatus.textContent, /Unlocked/, "Status should say Unlocked"); + }); +});