diff --git a/js/state/appState.js b/js/state/appState.js index ca74016b..17515636 100644 --- a/js/state/appState.js +++ b/js/state/appState.js @@ -131,7 +131,7 @@ export function setVideosMap(value) { if (value instanceof Map) { return updateKey("videosMap", value); } - return updateKey("videosMap", value ?? null); + return updateKey("videosMap", null); } export function getVideoSubscription() { diff --git a/js/state/profileCache.js b/js/state/profileCache.js index 29e9acbd..eacf26f4 100644 --- a/js/state/profileCache.js +++ b/js/state/profileCache.js @@ -167,7 +167,9 @@ class ProfileCache { } const normalized = { - name: profile.name || profile.display_name || "Unknown", + name: + sanitizeProfileString(profile.name || profile.display_name) || + "Unknown", picture: sanitizeProfileMediaUrl(profile.picture || profile.image) || "assets/svg/default-profile.svg", diff --git a/tests/state/appState.test.mjs b/tests/state/appState.test.mjs new file mode 100644 index 00000000..e087046c --- /dev/null +++ b/tests/state/appState.test.mjs @@ -0,0 +1,76 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert"; +import * as appState from "../../js/state/appState.js"; + +describe("AppState", () => { + beforeEach(() => { + appState.resetAppState(); + }); + + test("getters and setters work for simple values", () => { + assert.strictEqual(appState.getPubkey(), null); + + appState.setPubkey("test_pubkey"); + assert.strictEqual(appState.getPubkey(), "test_pubkey"); + + appState.setPubkey(null); + assert.strictEqual(appState.getPubkey(), null); + }); + + test("subscribeToAppStateKey fires on change", (t, done) => { + const unsubscribe = appState.subscribeToAppStateKey("pubkey", (val, prev) => { + assert.strictEqual(val, "new_pubkey"); + assert.strictEqual(prev, null); + unsubscribe(); + done(); + }); + + appState.setPubkey("new_pubkey"); + }); + + test("subscribeToAppState fires on any change", (t, done) => { + const unsubscribe = appState.subscribeToAppState((snapshot, { key, value }) => { + if (key === "currentUserNpub") { + assert.strictEqual(value, "npub1..."); + assert.strictEqual(snapshot.currentUserNpub, "npub1..."); + unsubscribe(); + done(); + } + }); + + appState.setCurrentUserNpub("npub1..."); + }); + + test("setModalState updates state and notifies", (t, done) => { + assert.strictEqual(appState.getModalState("testModal"), false); + + const unsubscribe = appState.subscribeToModalState("testModal", (isOpen) => { + assert.strictEqual(isOpen, true); + unsubscribe(); + done(); + }); + + const result = appState.setModalState("testModal", true); + assert.strictEqual(result, true); + assert.strictEqual(appState.getModalState("testModal"), true); + }); + + test("resetAppState clears everything", () => { + appState.setPubkey("pubkey"); + appState.setModalState("modal", true); + + appState.resetAppState(); + + assert.strictEqual(appState.getPubkey(), null); + assert.strictEqual(appState.getModalState("modal"), false); + }); + + test("setVideosMap only accepts Maps or null", () => { + const map = new Map(); + appState.setVideosMap(map); + assert.strictEqual(appState.getVideosMap(), map); + + appState.setVideosMap("not a map"); + assert.strictEqual(appState.getVideosMap(), null); + }); +}); diff --git a/tests/state/profileCache.test.mjs b/tests/state/profileCache.test.mjs new file mode 100644 index 00000000..eca0536f --- /dev/null +++ b/tests/state/profileCache.test.mjs @@ -0,0 +1,170 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert"; +import { profileCache } from "../../js/state/profileCache.js"; +import { NOTE_TYPES } from "../../js/nostrEventSchemas.js"; + +describe("ProfileCache", () => { + const TEST_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001"; + const ANOTHER_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002"; + + beforeEach(() => { + // Reset internal state + profileCache.activePubkey = null; + profileCache.memoryCache.clear(); + profileCache.listeners.clear(); + // Clear mock localStorage + if (globalThis.localStorage) { + globalThis.localStorage.clear(); + } + }); + + test("setActiveProfile normalizes and sets active pubkey", () => { + profileCache.setActiveProfile(" " + TEST_PUBKEY.toUpperCase() + " "); + assert.strictEqual(profileCache.getActiveProfile(), TEST_PUBKEY); + }); + + test("setActiveProfile clears memory cache for old profile", () => { + profileCache.setActiveProfile(TEST_PUBKEY); + profileCache.setMemoryDataForPubkey(TEST_PUBKEY, "test_section", { foo: "bar" }); + assert.ok(profileCache.getProfileData(TEST_PUBKEY, "test_section")); + + // Switch profile + profileCache.setActiveProfile(ANOTHER_PUBKEY); + + // Check old data is gone from memory + // Note: profileCache.getProfileData falls back to localStorage, so we should ensure localStorage is empty + // or specifically check memoryCache. + const memKey = `${TEST_PUBKEY}:test_section`; + assert.strictEqual(profileCache.memoryCache.has(memKey), false); + }); + + test("resolveAddressKey generates correct keys based on policy", () => { + profileCache.setActiveProfile(TEST_PUBKEY); + + // Watch History: kind:pubkey:d (dTag default) + // NOTE_TYPES.WATCH_HISTORY is usually 30078 or similar. + // But CACHE_POLICIES keys are the noteType string from schemas. + // Let's rely on what the code actually does. + + const key = profileCache.resolveAddressKey("watchHistory", { pubkey: TEST_PUBKEY }); + // Expect: bitvid:profile:{pubkey}:{noteType}:{dTag}:v1 + // The code maps "watchHistory" to NOTE_TYPES.WATCH_HISTORY via SECTION_TO_NOTE_TYPE. + const expectedPrefix = `bitvid:profile:${TEST_PUBKEY}:${NOTE_TYPES.WATCH_HISTORY}`; + assert.ok(key.startsWith(expectedPrefix)); + assert.ok(key.includes(":v1")); + }); + + test("setProfile normalizes and persists profile data", () => { + profileCache.setActiveProfile(TEST_PUBKEY); + + const rawProfile = { + name: " Test User ", + picture: "https://example.com/pic.jpg", + about: " About me ", + website: " https://example.com ", + lud16: "user@ln.address", + }; + + profileCache.setProfile(TEST_PUBKEY, rawProfile); + + const stored = profileCache.getProfile(TEST_PUBKEY); + assert.ok(stored); + assert.strictEqual(stored.name, "Test User"); // Trimmed + assert.strictEqual(stored.picture, "https://example.com/pic.jpg"); + assert.strictEqual(stored.about, "About me"); // Trimmed + assert.strictEqual(stored.website, "https://example.com"); // Trimmed + assert.strictEqual(stored.lud16, "user@ln.address"); + + // Check persistence + // "profile" section maps to NOTE_TYPES.PROFILE_METADATA (kind 0) + // Policy addressing is kind:pubkey + const storageKey = profileCache.getStorageKey(TEST_PUBKEY, "profile"); + const storedJson = localStorage.getItem(storageKey); + assert.ok(storedJson, "Should be saved to localStorage"); + const parsed = JSON.parse(storedJson); + assert.deepStrictEqual(parsed.profile, stored); + }); + + test("getProfileData checks TTL", (t) => { + // Mock Date.now + const originalDateNow = Date.now; + let mockNow = 1000000; + Date.now = () => mockNow; + + try { + // NOTE_TYPES.VIDEO_POST has 10 min TTL (600,000 ms) + // We need to use a section that maps to a policy with TTL. + // "profile" has Infinity TTL. + // Let's look at CACHE_POLICIES. + // VIDEO_POST is indexedDB, so ProfileCache might not handle it via localStorage path? + // ProfileCache.getStorageTier checks policy. + // CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage is INDEXED_DB. + + // We need a LOCAL_STORAGE policy with finite TTL. + // WATCH_HISTORY is LOCAL_STORAGE and 24h TTL. + + const section = "watchHistory"; + profileCache.setActiveProfile(TEST_PUBKEY); + + const data = { + items: [], + timestamp: mockNow // Saved at 1000000 + }; + + profileCache.set(section, data); + + // Verify immediate retrieval + const retrieved = profileCache.get(section); + assert.deepStrictEqual(retrieved, data); + + // Fast forward 25 hours (25 * 60 * 60 * 1000 = 90,000,000) + mockNow += 90000000; + + // Clear memory cache to force load from storage + profileCache.memoryCache.clear(); + + const expired = profileCache.get(section); + assert.strictEqual(expired, null, "Should return null for expired data"); + + } finally { + Date.now = originalDateNow; + } + }); + + test("setProfile sanitizes XSS in media URLs", () => { + const maliciousProfile = { + name: "Hacker", + picture: "javascript:alert(1)", + banner: "data:image/svg+xml;base64,PHN2ZyBvbG9hZD0iYWxlcnQoMSkiPjwvc3ZnPg==", // basic svg xss vector + }; + + profileCache.setProfile(TEST_PUBKEY, maliciousProfile); + const stored = profileCache.getProfile(TEST_PUBKEY); + + // sanitizeProfileMediaUrl returns empty string or null for invalid/dangerous schemes + // The implementation uses `sanitizeProfileMediaUrl` from `../utils/profileMedia.js`. + // If it detects bad schemes, it might clear it. + // Assuming "javascript:" is stripped. + assert.notStrictEqual(stored.picture, "javascript:alert(1)"); + // If it returns null or empty string, that's good. + + // Note: We are relying on `sanitizeProfileMediaUrl` behavior here. + // If that function is robust, these assertions pass. + }); + + test("emit fires events on update", (t, done) => { + profileCache.setActiveProfile(TEST_PUBKEY); + + const unsubscribe = profileCache.subscribe((event, detail) => { + if (event === "update") { + assert.strictEqual(detail.pubkey, TEST_PUBKEY); + assert.strictEqual(detail.section, "test_section"); + assert.deepStrictEqual(detail.data, { foo: "bar" }); + unsubscribe(); + done(); + } + }); + + profileCache.set("test_section", { foo: "bar" }); + }); +});