545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js";
|
||
import { createSeenTracker } from "./seen-tracker.js";
|
||
import { createMetrics, type MetricName } from "./metrics.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);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|