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"); + }); +});