Spaces:
Paused
Paused
| import { describe, expect, it } from "vitest"; | |
| import { createMetrics, type MetricName } from "./metrics.js"; | |
| import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js"; | |
| import { createSeenTracker } from "./seen-tracker.js"; | |
| // ============================================================================ | |
| // Fuzz Tests for validatePrivateKey | |
| // ============================================================================ | |
| describe("validatePrivateKey fuzz", () => { | |
| describe("type confusion", () => { | |
| it("rejects null input", () => { | |
| expect(() => validatePrivateKey(null as unknown as string)).toThrow(); | |
| }); | |
| it("rejects undefined input", () => { | |
| expect(() => validatePrivateKey(undefined as unknown as string)).toThrow(); | |
| }); | |
| it("rejects number input", () => { | |
| expect(() => validatePrivateKey(123 as unknown as string)).toThrow(); | |
| }); | |
| it("rejects boolean input", () => { | |
| expect(() => validatePrivateKey(true as unknown as string)).toThrow(); | |
| }); | |
| it("rejects object input", () => { | |
| expect(() => validatePrivateKey({} as unknown as string)).toThrow(); | |
| }); | |
| it("rejects array input", () => { | |
| expect(() => validatePrivateKey([] as unknown as string)).toThrow(); | |
| }); | |
| it("rejects function input", () => { | |
| expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow(); | |
| }); | |
| }); | |
| describe("unicode attacks", () => { | |
| it("rejects unicode lookalike characters", () => { | |
| // Using zero-width characters | |
| const withZeroWidth = | |
| "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf"; | |
| expect(() => validatePrivateKey(withZeroWidth)).toThrow(); | |
| }); | |
| it("rejects RTL override", () => { | |
| const withRtl = "\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| expect(() => validatePrivateKey(withRtl)).toThrow(); | |
| }); | |
| it("rejects homoglyph 'a' (Cyrillic а)", () => { | |
| // Using Cyrillic 'а' (U+0430) instead of Latin 'a' | |
| const withCyrillicA = "0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| expect(() => validatePrivateKey(withCyrillicA)).toThrow(); | |
| }); | |
| it("rejects emoji", () => { | |
| const withEmoji = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀"; | |
| expect(() => validatePrivateKey(withEmoji)).toThrow(); | |
| }); | |
| it("rejects combining characters", () => { | |
| // 'a' followed by combining acute accent | |
| const withCombining = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301"; | |
| expect(() => validatePrivateKey(withCombining)).toThrow(); | |
| }); | |
| }); | |
| describe("injection attempts", () => { | |
| it("rejects null byte injection", () => { | |
| const withNullByte = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f"; | |
| expect(() => validatePrivateKey(withNullByte)).toThrow(); | |
| }); | |
| it("rejects newline injection", () => { | |
| const withNewline = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf"; | |
| expect(() => validatePrivateKey(withNewline)).toThrow(); | |
| }); | |
| it("rejects carriage return injection", () => { | |
| const withCR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf"; | |
| expect(() => validatePrivateKey(withCR)).toThrow(); | |
| }); | |
| it("rejects tab injection", () => { | |
| const withTab = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf"; | |
| expect(() => validatePrivateKey(withTab)).toThrow(); | |
| }); | |
| it("rejects form feed injection", () => { | |
| const withFormFeed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff"; | |
| expect(() => validatePrivateKey(withFormFeed)).toThrow(); | |
| }); | |
| }); | |
| describe("edge cases", () => { | |
| it("rejects very long string", () => { | |
| const veryLong = "a".repeat(10000); | |
| expect(() => validatePrivateKey(veryLong)).toThrow(); | |
| }); | |
| it("rejects string of spaces matching length", () => { | |
| const spaces = " ".repeat(64); | |
| expect(() => validatePrivateKey(spaces)).toThrow(); | |
| }); | |
| it("rejects hex with spaces between characters", () => { | |
| const withSpaces = | |
| "01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef"; | |
| expect(() => validatePrivateKey(withSpaces)).toThrow(); | |
| }); | |
| }); | |
| describe("nsec format edge cases", () => { | |
| it("rejects nsec with invalid bech32 characters", () => { | |
| // 'b', 'i', 'o' are not valid bech32 characters | |
| const invalidBech32 = "nsec1qypqxpq9qtpqscx7peytbfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l"; | |
| expect(() => validatePrivateKey(invalidBech32)).toThrow(); | |
| }); | |
| it("rejects nsec with wrong prefix", () => { | |
| expect(() => validatePrivateKey("nsec0aaaa")).toThrow(); | |
| }); | |
| it("rejects partial nsec", () => { | |
| expect(() => validatePrivateKey("nsec1")).toThrow(); | |
| }); | |
| }); | |
| }); | |
| // ============================================================================ | |
| // Fuzz Tests for isValidPubkey | |
| // ============================================================================ | |
| describe("isValidPubkey fuzz", () => { | |
| describe("type confusion", () => { | |
| it("handles null gracefully", () => { | |
| expect(isValidPubkey(null as unknown as string)).toBe(false); | |
| }); | |
| it("handles undefined gracefully", () => { | |
| expect(isValidPubkey(undefined as unknown as string)).toBe(false); | |
| }); | |
| it("handles number gracefully", () => { | |
| expect(isValidPubkey(123 as unknown as string)).toBe(false); | |
| }); | |
| it("handles object gracefully", () => { | |
| expect(isValidPubkey({} as unknown as string)).toBe(false); | |
| }); | |
| }); | |
| describe("malicious inputs", () => { | |
| it("rejects __proto__ key", () => { | |
| expect(isValidPubkey("__proto__")).toBe(false); | |
| }); | |
| it("rejects constructor key", () => { | |
| expect(isValidPubkey("constructor")).toBe(false); | |
| }); | |
| it("rejects toString key", () => { | |
| expect(isValidPubkey("toString")).toBe(false); | |
| }); | |
| }); | |
| }); | |
| // ============================================================================ | |
| // Fuzz Tests for normalizePubkey | |
| // ============================================================================ | |
| describe("normalizePubkey fuzz", () => { | |
| describe("prototype pollution attempts", () => { | |
| it("throws for __proto__", () => { | |
| expect(() => normalizePubkey("__proto__")).toThrow(); | |
| }); | |
| it("throws for constructor", () => { | |
| expect(() => normalizePubkey("constructor")).toThrow(); | |
| }); | |
| it("throws for prototype", () => { | |
| expect(() => normalizePubkey("prototype")).toThrow(); | |
| }); | |
| }); | |
| describe("case sensitivity", () => { | |
| it("normalizes uppercase to lowercase", () => { | |
| const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; | |
| const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| expect(normalizePubkey(upper)).toBe(lower); | |
| }); | |
| it("normalizes mixed case to lowercase", () => { | |
| const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf"; | |
| const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| expect(normalizePubkey(mixed)).toBe(lower); | |
| }); | |
| }); | |
| }); | |
| // ============================================================================ | |
| // Fuzz Tests for SeenTracker | |
| // ============================================================================ | |
| describe("SeenTracker fuzz", () => { | |
| describe("malformed IDs", () => { | |
| it("handles empty string IDs", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| expect(() => tracker.add("")).not.toThrow(); | |
| expect(tracker.peek("")).toBe(true); | |
| tracker.stop(); | |
| }); | |
| it("handles very long IDs", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| const longId = "a".repeat(100000); | |
| expect(() => tracker.add(longId)).not.toThrow(); | |
| expect(tracker.peek(longId)).toBe(true); | |
| tracker.stop(); | |
| }); | |
| it("handles unicode IDs", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| const unicodeId = "事件ID_🎉_тест"; | |
| expect(() => tracker.add(unicodeId)).not.toThrow(); | |
| expect(tracker.peek(unicodeId)).toBe(true); | |
| tracker.stop(); | |
| }); | |
| it("handles IDs with null bytes", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| const idWithNull = "event\x00id"; | |
| expect(() => tracker.add(idWithNull)).not.toThrow(); | |
| expect(tracker.peek(idWithNull)).toBe(true); | |
| tracker.stop(); | |
| }); | |
| it("handles prototype property names as IDs", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| // These should not affect the tracker's internal operation | |
| expect(() => tracker.add("__proto__")).not.toThrow(); | |
| expect(() => tracker.add("constructor")).not.toThrow(); | |
| expect(() => tracker.add("toString")).not.toThrow(); | |
| expect(() => tracker.add("hasOwnProperty")).not.toThrow(); | |
| expect(tracker.peek("__proto__")).toBe(true); | |
| expect(tracker.peek("constructor")).toBe(true); | |
| expect(tracker.peek("toString")).toBe(true); | |
| expect(tracker.peek("hasOwnProperty")).toBe(true); | |
| tracker.stop(); | |
| }); | |
| }); | |
| describe("rapid operations", () => { | |
| it("handles rapid add/check cycles", () => { | |
| const tracker = createSeenTracker({ maxEntries: 1000 }); | |
| for (let i = 0; i < 10000; i++) { | |
| const id = `event-${i}`; | |
| tracker.add(id); | |
| // Recently added should be findable | |
| if (i < 1000) { | |
| tracker.peek(id); | |
| } | |
| } | |
| // Size should be capped at maxEntries | |
| expect(tracker.size()).toBeLessThanOrEqual(1000); | |
| tracker.stop(); | |
| }); | |
| it("handles concurrent-style operations", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| // Simulate interleaved operations | |
| for (let i = 0; i < 100; i++) { | |
| tracker.add(`add-${i}`); | |
| tracker.peek(`peek-${i}`); | |
| tracker.has(`has-${i}`); | |
| if (i % 10 === 0) { | |
| tracker.delete(`add-${i - 5}`); | |
| } | |
| } | |
| expect(() => tracker.size()).not.toThrow(); | |
| tracker.stop(); | |
| }); | |
| }); | |
| describe("seed edge cases", () => { | |
| it("handles empty seed array", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| expect(() => tracker.seed([])).not.toThrow(); | |
| expect(tracker.size()).toBe(0); | |
| tracker.stop(); | |
| }); | |
| it("handles seed with duplicate IDs", () => { | |
| const tracker = createSeenTracker({ maxEntries: 100 }); | |
| tracker.seed(["id1", "id1", "id1", "id2", "id2"]); | |
| expect(tracker.size()).toBe(2); | |
| tracker.stop(); | |
| }); | |
| it("handles seed larger than maxEntries", () => { | |
| const tracker = createSeenTracker({ maxEntries: 5 }); | |
| const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`); | |
| tracker.seed(ids); | |
| expect(tracker.size()).toBeLessThanOrEqual(5); | |
| tracker.stop(); | |
| }); | |
| }); | |
| }); | |
| // ============================================================================ | |
| // Fuzz Tests for Metrics | |
| // ============================================================================ | |
| describe("Metrics fuzz", () => { | |
| describe("invalid metric names", () => { | |
| it("handles unknown metric names gracefully", () => { | |
| const metrics = createMetrics(); | |
| // Cast to bypass type checking - testing runtime behavior | |
| expect(() => { | |
| metrics.emit("invalid.metric.name" as MetricName); | |
| }).not.toThrow(); | |
| }); | |
| }); | |
| describe("invalid label values", () => { | |
| it("handles null relay label", () => { | |
| const metrics = createMetrics(); | |
| expect(() => { | |
| metrics.emit("relay.connect", 1, { relay: null as unknown as string }); | |
| }).not.toThrow(); | |
| }); | |
| it("handles undefined relay label", () => { | |
| const metrics = createMetrics(); | |
| expect(() => { | |
| metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }); | |
| }).not.toThrow(); | |
| }); | |
| it("handles very long relay URL", () => { | |
| const metrics = createMetrics(); | |
| const longUrl = "wss://" + "a".repeat(10000) + ".com"; | |
| expect(() => { | |
| metrics.emit("relay.connect", 1, { relay: longUrl }); | |
| }).not.toThrow(); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(snapshot.relays[longUrl]).toBeDefined(); | |
| }); | |
| }); | |
| describe("extreme values", () => { | |
| it("handles NaN value", () => { | |
| const metrics = createMetrics(); | |
| expect(() => metrics.emit("event.received", NaN)).not.toThrow(); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(isNaN(snapshot.eventsReceived)).toBe(true); | |
| }); | |
| it("handles Infinity value", () => { | |
| const metrics = createMetrics(); | |
| expect(() => metrics.emit("event.received", Infinity)).not.toThrow(); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(snapshot.eventsReceived).toBe(Infinity); | |
| }); | |
| it("handles negative value", () => { | |
| const metrics = createMetrics(); | |
| metrics.emit("event.received", -1); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(snapshot.eventsReceived).toBe(-1); | |
| }); | |
| it("handles very large value", () => { | |
| const metrics = createMetrics(); | |
| metrics.emit("event.received", Number.MAX_SAFE_INTEGER); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(snapshot.eventsReceived).toBe(Number.MAX_SAFE_INTEGER); | |
| }); | |
| }); | |
| describe("rapid emissions", () => { | |
| it("handles many rapid emissions", () => { | |
| const events: unknown[] = []; | |
| const metrics = createMetrics((e) => events.push(e)); | |
| for (let i = 0; i < 10000; i++) { | |
| metrics.emit("event.received"); | |
| } | |
| expect(events).toHaveLength(10000); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(snapshot.eventsReceived).toBe(10000); | |
| }); | |
| }); | |
| describe("reset during operation", () => { | |
| it("handles reset mid-operation safely", () => { | |
| const metrics = createMetrics(); | |
| metrics.emit("event.received"); | |
| metrics.emit("event.received"); | |
| metrics.reset(); | |
| metrics.emit("event.received"); | |
| const snapshot = metrics.getSnapshot(); | |
| expect(snapshot.eventsReceived).toBe(1); | |
| }); | |
| }); | |
| }); | |
| // ============================================================================ | |
| // Event Shape Validation (simulating malformed events) | |
| // ============================================================================ | |
| describe("Event shape validation", () => { | |
| describe("malformed event structures", () => { | |
| // These test what happens if malformed data somehow gets through | |
| it("identifies missing required fields", () => { | |
| const malformedEvents = [ | |
| {}, // empty | |
| { id: "abc" }, // missing pubkey, created_at, etc. | |
| { id: null, pubkey: null }, // null values | |
| { id: 123, pubkey: 456 }, // wrong types | |
| { tags: "not-an-array" }, // wrong type for tags | |
| { tags: [[1, 2, 3]] }, // wrong type for tag elements | |
| ]; | |
| for (const event of malformedEvents) { | |
| // These should be caught by shape validation before processing | |
| const hasId = typeof event?.id === "string"; | |
| const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string"; | |
| const hasTags = Array.isArray((event as { tags?: unknown })?.tags); | |
| // At least one should be invalid | |
| expect(hasId && hasPubkey && hasTags).toBe(false); | |
| } | |
| }); | |
| }); | |
| describe("timestamp edge cases", () => { | |
| const testTimestamps = [ | |
| { value: NaN, desc: "NaN" }, | |
| { value: Infinity, desc: "Infinity" }, | |
| { value: -Infinity, desc: "-Infinity" }, | |
| { value: -1, desc: "negative" }, | |
| { value: 0, desc: "zero" }, | |
| { value: 253402300800, desc: "year 10000" }, // Far future | |
| { value: -62135596800, desc: "year 0001" }, // Far past | |
| { value: 1.5, desc: "float" }, | |
| ]; | |
| for (const { value, desc } of testTimestamps) { | |
| it(`handles ${desc} timestamp`, () => { | |
| const isValidTimestamp = | |
| typeof value === "number" && | |
| !isNaN(value) && | |
| isFinite(value) && | |
| value >= 0 && | |
| Number.isInteger(value); | |
| // Timestamps should be validated as positive integers | |
| if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) { | |
| expect(isValidTimestamp).toBe(false); | |
| } | |
| }); | |
| } | |
| }); | |
| }); | |
| // ============================================================================ | |
| // JSON parsing edge cases (simulating relay responses) | |
| // ============================================================================ | |
| describe("JSON parsing edge cases", () => { | |
| const malformedJsonCases = [ | |
| { input: "", desc: "empty string" }, | |
| { input: "null", desc: "null literal" }, | |
| { input: "undefined", desc: "undefined literal" }, | |
| { input: "{", desc: "incomplete object" }, | |
| { input: "[", desc: "incomplete array" }, | |
| { input: '{"key": undefined}', desc: "undefined value" }, | |
| { input: "{'key': 'value'}", desc: "single quotes" }, | |
| { input: '{"key": NaN}', desc: "NaN value" }, | |
| { input: '{"key": Infinity}', desc: "Infinity value" }, | |
| { input: "\x00", desc: "null byte" }, | |
| { input: "abc", desc: "plain string" }, | |
| { input: "123", desc: "plain number" }, | |
| ]; | |
| for (const { input, desc } of malformedJsonCases) { | |
| it(`handles malformed JSON: ${desc}`, () => { | |
| let parsed: unknown; | |
| let parseError = false; | |
| try { | |
| parsed = JSON.parse(input); | |
| } catch { | |
| parseError = true; | |
| } | |
| // Either it throws or produces something that needs validation | |
| if (!parseError) { | |
| // If it parsed, we need to validate the structure | |
| const isValidRelayMessage = | |
| Array.isArray(parsed) && parsed.length >= 2 && typeof parsed[0] === "string"; | |
| // Most malformed cases won't produce valid relay messages | |
| if (["null literal", "plain number", "plain string"].includes(desc)) { | |
| expect(isValidRelayMessage).toBe(false); | |
| } | |
| } | |
| }); | |
| } | |
| }); | |