import { describe, expect, it, vi, beforeEach } from "vitest"; import { verifyEvent, getPublicKey } from "nostr-tools"; import { createProfileEvent, profileToContent, contentToProfile, validateProfile, sanitizeProfileForDisplay, type ProfileContent, } from "./nostr-profile.js"; import type { NostrProfile } from "./config-schema.js"; // Test private key (DO NOT use in production - this is a known test key) const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const TEST_SK = new Uint8Array( TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)) ); const TEST_PUBKEY = getPublicKey(TEST_SK); // ============================================================================ // Profile Content Conversion Tests // ============================================================================ describe("profileToContent", () => { it("converts full profile to NIP-01 content format", () => { const profile: NostrProfile = { name: "testuser", displayName: "Test User", about: "A test user for unit testing", picture: "https://example.com/avatar.png", banner: "https://example.com/banner.png", website: "https://example.com", nip05: "testuser@example.com", lud16: "testuser@walletofsatoshi.com", }; const content = profileToContent(profile); expect(content.name).toBe("testuser"); expect(content.display_name).toBe("Test User"); expect(content.about).toBe("A test user for unit testing"); expect(content.picture).toBe("https://example.com/avatar.png"); expect(content.banner).toBe("https://example.com/banner.png"); expect(content.website).toBe("https://example.com"); expect(content.nip05).toBe("testuser@example.com"); expect(content.lud16).toBe("testuser@walletofsatoshi.com"); }); it("omits undefined fields from content", () => { const profile: NostrProfile = { name: "minimaluser", }; const content = profileToContent(profile); expect(content.name).toBe("minimaluser"); expect("display_name" in content).toBe(false); expect("about" in content).toBe(false); expect("picture" in content).toBe(false); }); it("handles empty profile", () => { const profile: NostrProfile = {}; const content = profileToContent(profile); expect(Object.keys(content)).toHaveLength(0); }); }); describe("contentToProfile", () => { it("converts NIP-01 content to profile format", () => { const content: ProfileContent = { name: "testuser", display_name: "Test User", about: "A test user", picture: "https://example.com/avatar.png", nip05: "test@example.com", }; const profile = contentToProfile(content); expect(profile.name).toBe("testuser"); expect(profile.displayName).toBe("Test User"); expect(profile.about).toBe("A test user"); expect(profile.picture).toBe("https://example.com/avatar.png"); expect(profile.nip05).toBe("test@example.com"); }); it("handles empty content", () => { const content: ProfileContent = {}; const profile = contentToProfile(content); expect(Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined)).toHaveLength(0); }); it("round-trips profile data", () => { const original: NostrProfile = { name: "roundtrip", displayName: "Round Trip Test", about: "Testing round-trip conversion", }; const content = profileToContent(original); const restored = contentToProfile(content); expect(restored.name).toBe(original.name); expect(restored.displayName).toBe(original.displayName); expect(restored.about).toBe(original.about); }); }); // ============================================================================ // Event Creation Tests // ============================================================================ describe("createProfileEvent", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-01-15T12:00:00Z")); }); it("creates a valid kind:0 event", () => { const profile: NostrProfile = { name: "testbot", about: "A test bot", }; const event = createProfileEvent(TEST_SK, profile); expect(event.kind).toBe(0); expect(event.pubkey).toBe(TEST_PUBKEY); expect(event.tags).toEqual([]); expect(event.id).toMatch(/^[0-9a-f]{64}$/); expect(event.sig).toMatch(/^[0-9a-f]{128}$/); }); it("includes profile content as JSON in event content", () => { const profile: NostrProfile = { name: "jsontest", displayName: "JSON Test User", about: "Testing JSON serialization", }; const event = createProfileEvent(TEST_SK, profile); const parsedContent = JSON.parse(event.content) as ProfileContent; expect(parsedContent.name).toBe("jsontest"); expect(parsedContent.display_name).toBe("JSON Test User"); expect(parsedContent.about).toBe("Testing JSON serialization"); }); it("produces a verifiable signature", () => { const profile: NostrProfile = { name: "signaturetest" }; const event = createProfileEvent(TEST_SK, profile); expect(verifyEvent(event)).toBe(true); }); it("uses current timestamp when no lastPublishedAt provided", () => { const profile: NostrProfile = { name: "timestamptest" }; const event = createProfileEvent(TEST_SK, profile); const expectedTimestamp = Math.floor(Date.now() / 1000); expect(event.created_at).toBe(expectedTimestamp); }); it("ensures monotonic timestamp when lastPublishedAt is in the future", () => { // Current time is 2024-01-15T12:00:00Z = 1705320000 const futureTimestamp = 1705320000 + 3600; // 1 hour in the future const profile: NostrProfile = { name: "monotonictest" }; const event = createProfileEvent(TEST_SK, profile, futureTimestamp); expect(event.created_at).toBe(futureTimestamp + 1); }); it("uses current time when lastPublishedAt is in the past", () => { const pastTimestamp = 1705320000 - 3600; // 1 hour in the past const profile: NostrProfile = { name: "pasttest" }; const event = createProfileEvent(TEST_SK, profile, pastTimestamp); const expectedTimestamp = Math.floor(Date.now() / 1000); expect(event.created_at).toBe(expectedTimestamp); }); vi.useRealTimers(); }); // ============================================================================ // Profile Validation Tests // ============================================================================ describe("validateProfile", () => { it("validates a correct profile", () => { const profile = { name: "validuser", about: "A valid user", picture: "https://example.com/pic.png", }; const result = validateProfile(profile); expect(result.valid).toBe(true); expect(result.profile).toBeDefined(); expect(result.errors).toBeUndefined(); }); it("rejects profile with invalid URL", () => { const profile = { name: "invalidurl", picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS }; const result = validateProfile(profile); expect(result.valid).toBe(false); expect(result.errors).toBeDefined(); expect(result.errors!.some((e) => e.includes("https://"))).toBe(true); }); it("rejects profile with javascript: URL", () => { const profile = { name: "xssattempt", picture: "javascript:alert('xss')", }; const result = validateProfile(profile); expect(result.valid).toBe(false); }); it("rejects profile with data: URL", () => { const profile = { name: "dataurl", picture: "data:image/png;base64,abc123", }; const result = validateProfile(profile); expect(result.valid).toBe(false); }); it("rejects name exceeding 256 characters", () => { const profile = { name: "a".repeat(257), }; const result = validateProfile(profile); expect(result.valid).toBe(false); expect(result.errors!.some((e) => e.includes("256"))).toBe(true); }); it("rejects about exceeding 2000 characters", () => { const profile = { about: "a".repeat(2001), }; const result = validateProfile(profile); expect(result.valid).toBe(false); expect(result.errors!.some((e) => e.includes("2000"))).toBe(true); }); it("accepts empty profile", () => { const result = validateProfile({}); expect(result.valid).toBe(true); }); it("rejects null input", () => { const result = validateProfile(null); expect(result.valid).toBe(false); }); it("rejects non-object input", () => { const result = validateProfile("not an object"); expect(result.valid).toBe(false); }); }); // ============================================================================ // Sanitization Tests // ============================================================================ describe("sanitizeProfileForDisplay", () => { it("escapes HTML in name field", () => { const profile: NostrProfile = { name: "", }; const sanitized = sanitizeProfileForDisplay(profile); expect(sanitized.name).toBe("<script>alert('xss')</script>"); }); it("escapes HTML in about field", () => { const profile: NostrProfile = { about: 'Check out ', }; const sanitized = sanitizeProfileForDisplay(profile); expect(sanitized.about).toBe( 'Check out <img src="x" onerror="alert(1)">' ); }); it("preserves URLs without modification", () => { const profile: NostrProfile = { picture: "https://example.com/pic.png", website: "https://example.com", }; const sanitized = sanitizeProfileForDisplay(profile); expect(sanitized.picture).toBe("https://example.com/pic.png"); expect(sanitized.website).toBe("https://example.com"); }); it("handles undefined fields", () => { const profile: NostrProfile = { name: "test", }; const sanitized = sanitizeProfileForDisplay(profile); expect(sanitized.name).toBe("test"); expect(sanitized.about).toBeUndefined(); expect(sanitized.picture).toBeUndefined(); }); it("escapes ampersands", () => { const profile: NostrProfile = { name: "Tom & Jerry", }; const sanitized = sanitizeProfileForDisplay(profile); expect(sanitized.name).toBe("Tom & Jerry"); }); it("escapes quotes", () => { const profile: NostrProfile = { about: 'Say "hello" to everyone', }; const sanitized = sanitizeProfileForDisplay(profile); expect(sanitized.about).toBe("Say "hello" to everyone"); }); }); // ============================================================================ // Edge Cases // ============================================================================ describe("edge cases", () => { it("handles emoji in profile fields", () => { const profile: NostrProfile = { name: "🤖 Bot", about: "I am a 🤖 robot! 🎉", }; const content = profileToContent(profile); expect(content.name).toBe("🤖 Bot"); expect(content.about).toBe("I am a 🤖 robot! 🎉"); const event = createProfileEvent(TEST_SK, profile); const parsed = JSON.parse(event.content) as ProfileContent; expect(parsed.name).toBe("🤖 Bot"); }); it("handles unicode in profile fields", () => { const profile: NostrProfile = { name: "日本語ユーザー", about: "Привет мир! 你好世界!", }; const content = profileToContent(profile); expect(content.name).toBe("日本語ユーザー"); const event = createProfileEvent(TEST_SK, profile); expect(verifyEvent(event)).toBe(true); }); it("handles newlines in about field", () => { const profile: NostrProfile = { about: "Line 1\nLine 2\nLine 3", }; const content = profileToContent(profile); expect(content.about).toBe("Line 1\nLine 2\nLine 3"); const event = createProfileEvent(TEST_SK, profile); const parsed = JSON.parse(event.content) as ProfileContent; expect(parsed.about).toBe("Line 1\nLine 2\nLine 3"); }); it("handles maximum length fields", () => { const profile: NostrProfile = { name: "a".repeat(256), about: "b".repeat(2000), }; const result = validateProfile(profile); expect(result.valid).toBe(true); const event = createProfileEvent(TEST_SK, profile); expect(verifyEvent(event)).toBe(true); }); });