Merge pull request #1258 from PR0M3TH3AN/test-coverage-expansion-18227096233978321770

Expand tests for generic connection payloads and R2-specific mapping
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-16 07:08:45 -05:00
commit 95b86a3a13
5 files changed
+440

No files matched your search

+11
View File
@@ -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",
+1
View File
@@ -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",
+97
View File
@@ -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)"
);
});
});
});
+167
View File
@@ -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<hexString>
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"
);
});
});
+164
View File
@@ -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 = `
<div id="uploadModal" class="hidden">
<form id="unifiedUploadForm">
<button id="btn-mode-upload"></button>
<button id="btn-mode-external"></button>
<div id="section-source-upload">
<button id="btn-storage-settings"></button>
<div id="section-storage-settings" class="hidden">
<input id="input-r2-account" />
<input id="input-r2-key" />
<input id="input-r2-secret" />
<input id="input-r2-domain" />
<button id="btn-save-settings"></button>
<button id="btn-storage-unlock" class="hidden"></button>
</div>
<div id="storage-status"></div>
<div id="storage-lock-status"></div>
</div>
<div id="section-source-external"></div>
</form>
<button id="btn-submit"></button>
<div id="submit-status"></div>
<button id="closeUploadModal"></button>
</div>
`;
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");
});
});