411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
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: "<script>alert('xss')</script>",
|
|
};
|
|
|
|
const sanitized = sanitizeProfileForDisplay(profile);
|
|
|
|
expect(sanitized.name).toBe("<script>alert('xss')</script>");
|
|
});
|
|
|
|
it("escapes HTML in about field", () => {
|
|
const profile: NostrProfile = {
|
|
about: 'Check out <img src="x" onerror="alert(1)">',
|
|
};
|
|
|
|
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);
|
|
});
|
|
});
|