From 22ea690805c34b9fc485f7e7ac5e9f4ee7e0785d Mon Sep 17 00:00:00 2001 From: Jerome Ludmann Date: Sun, 5 Apr 2026 09:16:34 -0300 Subject: [PATCH 1/2] test(core): add resilience tests for parser and client --- core/client_test.ts | 126 +++++++++++++++++++++++++++++++++++++++++++ core/parsers_test.ts | 105 +++++++++++++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/core/client_test.ts b/core/client_test.ts index 4ae4ca52..4388a40f 100644 --- a/core/client_test.ts +++ b/core/client_test.ts @@ -434,4 +434,130 @@ describe("core/client", (test) => { assertEquals(triggered, 1); }); + + test("survive unknown commands from server", async () => { + const { client, server } = mock(); + await client.connect("host"); + + server.send([ + "FOOBAR", + "BAZQUX param1 param2", + "123 me :unknown numeric", + "PING :alive", + ]); + + const msg = await client.once("raw:ping"); + assertEquals(msg.params, ["alive"]); + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive empty lines from server", async () => { + const { client, server } = mock(); + await client.connect("host"); + + server.send([ + "", + " ", + "PING :ok", + ]); + + const msg = await client.once("raw:ping"); + assertEquals(msg.params, ["ok"]); + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive malformed prefix from server", async () => { + const { client, server } = mock(); + await client.connect("host"); + + server.send([ + ":!@ PRIVMSG #ch :hello", + ":@! PRIVMSG #ch :hello", + ": PRIVMSG #ch :hello", + "PING :ok", + ]); + + const msg = await client.once("raw:ping"); + assertEquals(msg.params, ["ok"]); + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive malformed tags from server", async () => { + const { client, server } = mock(); + await client.connect("host"); + + server.send([ + "@ PING :1", + "@=== PING :2", + "@;; PING :3", + "@key PING :4", + "PING :ok", + ]); + + const msg = await client.once("raw:ping"); + assertEquals(msg.command, "ping"); + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive server shutdown mid-conversation", async () => { + const { client, server } = mock(); + await client.connect("host"); + + server.send("PING :before"); + await client.once("raw:ping"); + + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive null bytes in messages", async () => { + const { client, server } = mock(); + await client.connect("host"); + + server.send(":nick!u@h PRIVMSG #ch :\x00null\x00bytes"); + const msg = await client.once("raw:privmsg"); + assertEquals(msg.params[1], "\x00null\x00bytes"); + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive very long message from server", async () => { + const { client, server } = mock({ bufferSize: 65536 }); + await client.connect("host"); + + const longText = "A".repeat(10_000); + server.send(`:nick!u@h PRIVMSG #ch :${longText}`); + const msg = await client.once("raw:privmsg"); + assertEquals(msg.params[1].length, 10_000); + server.shutdown(); + await client.once("disconnected"); + }); + + test("survive flood of messages", async () => { + const { client, server } = mock(); + await client.connect("host"); + + const N = 80; + let count = 0; + const done = new Promise((resolve) => { + const off = client.on("raw:privmsg", () => { + if (++count === N) { + off(); + resolve(); + } + }); + }); + + server.send( + Array.from({ length: N }, (_, i) => `:nick!u@h PRIVMSG #ch :msg${i}`), + ); + await done; + assertEquals(count, N); + server.shutdown(); + await client.once("disconnected"); + }); }); diff --git a/core/parsers_test.ts b/core/parsers_test.ts index deab82bf..14672573 100644 --- a/core/parsers_test.ts +++ b/core/parsers_test.ts @@ -1,6 +1,11 @@ import { assertEquals } from "@std/assert"; import { describe } from "../testing/helpers.ts"; -import { escapeTagValue, parseChunk, unescapeTagValue } from "./parsers.ts"; +import { + escapeTagValue, + parseChunk, + parseSource, + unescapeTagValue, +} from "./parsers.ts"; describe("core/parsers", (test) => { test("parse message without prefix", () => { @@ -170,4 +175,102 @@ describe("core/parsers", (test) => { }, ]); }); + + test("survive empty string", () => { + const [msgs, remainder] = parseChunk(""); + assertEquals(msgs, []); + assertEquals(remainder, ""); + }); + + test("survive bare \\r\\n", () => { + parseChunk("\r\n"); + parseChunk("\r\n\r\n\r\n"); + }); + + test("survive only spaces", () => { + parseChunk(" \r\n"); + }); + + test("survive unknown command", () => { + const [msgs] = parseChunk("FOOBAR\r\n"); + assertEquals(msgs.length, 1); + assertEquals(msgs[0].params, []); + }); + + test("survive command with no params", () => { + const [msgs] = parseChunk("PING\r\n"); + assertEquals(msgs.length, 1); + assertEquals(msgs[0].command, "ping"); + assertEquals(msgs[0].params, []); + }); + + test("survive prefix with no command", () => { + parseChunk(":server\r\n"); + }); + + test("survive lone @ (tag marker with no data)", () => { + parseChunk("@\r\n"); + }); + + test("survive malformed tags", () => { + parseChunk("@ PING\r\n"); + parseChunk("@= PING\r\n"); + parseChunk("@; PING\r\n"); + parseChunk("@;; PING\r\n"); + parseChunk("@=== PING\r\n"); + parseChunk("@key PING\r\n"); + parseChunk("@ke=y=z PING\r\n"); + }); + + test("survive prefix with only ! or @", () => { + parseChunk(":!@ PING\r\n"); + parseChunk(":@ PING\r\n"); + parseChunk(":! PING\r\n"); + parseChunk(":@! PING\r\n"); + }); + + test("survive null bytes", () => { + parseChunk(":n\0ick PRIVMSG #ch :\0\r\n"); + }); + + test("survive unicode and emoji", () => { + parseChunk(":nïck!üser@hôst PRIVMSG #chännel :🔥🎉\r\n"); + }); + + test("survive very long message", () => { + const longText = "A".repeat(100_000); + parseChunk(`:nick!u@h PRIVMSG #ch :${longText}\r\n`); + }); + + test("survive trailing without colon", () => { + const [msgs] = parseChunk(":server 001 nick welcome\r\n"); + assertEquals(msgs.length, 1); + }); + + test("survive only \\r or only \\n", () => { + const [msgs1, r1] = parseChunk("PING\r"); + assertEquals(msgs1, []); + assertEquals(r1, "PING\r"); + + const [msgs2, r2] = parseChunk("PING\n"); + assertEquals(msgs2, []); + assertEquals(r2, "PING\n"); + }); + + test("survive mixed valid and garbage lines", () => { + parseChunk( + "PING :ok\r\n\r\n:::garbage\r\n@@@\r\n:nick PRIVMSG #ch :hi\r\n", + ); + }); + + test("parseSource with empty string", () => { + const source = parseSource(""); + assertEquals(source.name, ""); + }); + + test("parseSource with only special chars", () => { + parseSource("!@"); + parseSource("@!"); + parseSource("!!!@@@"); + }); }); From 55fccc75b9e678126e35918e6d4747cc1a3de170 Mon Sep 17 00:00:00 2001 From: Jerome Ludmann Date: Sun, 5 Apr 2026 09:23:37 -0300 Subject: [PATCH 2/2] test(plugins): improve branch coverage for 6 plugins --- deno.json | 2 +- plugins/batch_test.ts | 23 +++++++++++++++++++++ plugins/cap_test.ts | 33 +++++++++++++++++++++++++++++++ plugins/monitor_test.ts | 32 ++++++++++++++++++++++++++++++ plugins/standard_replies_test.ts | 28 ++++++++++++++++++++++++++ plugins/userhost_in_names_test.ts | 15 ++++++++++++++ plugins/who_test.ts | 28 ++++++++++++++++++++++++++ 7 files changed, 160 insertions(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 1f1ec2f4..6fda880e 100644 --- a/deno.json +++ b/deno.json @@ -60,7 +60,7 @@ "lint": "deno lint", "fmt": "deno fmt", "test": "deno test --failfast --allow-net core/*_test.ts plugins/*_test.ts runtime/deno_test.ts --parallel", - "coverage": "rm -rf coverage && deno test --allow-net --parallel --coverage=coverage/cov_profile core/*_test.ts plugins/*_test.ts runtime/deno_test.ts && deno coverage --exclude=\"_test.ts\" --exclude=\"/testing/\" coverage/cov_profile", + "coverage": "rm -rf coverage && deno test --allow-net --parallel --coverage=coverage/cov_profile core/*_test.ts plugins/*_test.ts runtime/deno_test.ts && deno coverage --exclude=\"_test.ts\" --exclude=\"testing/\" --exclude=\"runtime/mod.ts\" coverage/cov_profile", "test:integration": "deno task test:integration:ergo && deno task test:integration:inspircd && deno task test:integration:unrealircd", "test:integration:ergo": "docker compose -f integration/ergo/docker-compose.yml down -v 2>/dev/null; docker compose -f integration/ergo/docker-compose.yml up -d --force-recreate && sleep 2 && IRCD=ergo deno test --allow-net --allow-read --allow-env integration/integration_test.ts --failfast ; docker compose -f integration/ergo/docker-compose.yml down -v", "test:integration:inspircd": "docker compose -f integration/inspircd/docker-compose.yml down -v 2>/dev/null; docker compose -f integration/inspircd/docker-compose.yml up -d --force-recreate && sleep 3 && IRCD=inspircd deno test --allow-net --allow-read --allow-env integration/integration_test.ts --failfast ; docker compose -f integration/inspircd/docker-compose.yml down -v", diff --git a/plugins/batch_test.ts b/plugins/batch_test.ts index 290b6798..63e1fbb4 100644 --- a/plugins/batch_test.ts +++ b/plugins/batch_test.ts @@ -106,6 +106,29 @@ describe("plugins/batch", (test) => { assertEquals(batches[1].count, 0); }); + test("ignore BATCH with no ref param", async () => { + const { client, server } = await mock(); + + let started = false; + client.on("batch_start", () => { + started = true; + }); + + server.send(":server BATCH"); + await delay(); + + assertEquals(started, false); + }); + + test("pass through message with unknown batch tag", async () => { + const { client, server } = await mock(); + + server.send("@batch=unknown :nick!user@host PRIVMSG #ch :hello"); + const msg = await client.once("raw:privmsg"); + + assertEquals(msg.params, ["#ch", "hello"]); + }); + test("ignore unknown BATCH -ref", async () => { const { client, server } = await mock(); diff --git a/plugins/cap_test.ts b/plugins/cap_test.ts index 5d1716ed..10122841 100644 --- a/plugins/cap_test.ts +++ b/plugins/cap_test.ts @@ -150,6 +150,39 @@ describe("plugins/cap", (test) => { assertEquals(capReq!.includes("sasl"), true); }); + test("negotiate sends CAP END when no caps are supported", async () => { + const { client, server } = await mockWithCaps("unknown-only"); + + client.utils.negotiateCapabilities({ + completeImmediately: true, + }); + server.receive(); // CAP LS 302 + + server.send(":server CAP me LS :still-unknown"); + await client.once("raw:cap"); + await tick(); + + assertEquals(server.receive(), ["CAP END"]); + }); + + test("negotiate sends CAP END on NAK with completeImmediately", async () => { + const { client, server } = await mockWithCaps(); + + client.utils.negotiateCapabilities({ completeImmediately: true }); + server.receive(); // CAP LS 302 + + server.send(":server CAP me LS :cap-notify multi-prefix"); + await client.once("raw:cap"); + await tick(); + server.receive(); // CAP REQ + + server.send(":server CAP me NAK :cap-notify multi-prefix"); + await client.once("cap:nak"); + await tick(); + + assertEquals(server.receive(), ["CAP END"]); + }); + test("track CAP ACK", async () => { const { client, server } = await mock(); diff --git a/plugins/monitor_test.ts b/plugins/monitor_test.ts index 5cd95894..25a32228 100644 --- a/plugins/monitor_test.ts +++ b/plugins/monitor_test.ts @@ -41,6 +41,17 @@ describe("plugins/monitor", (test) => { assertEquals(client.state.monitorList.has("nick1"), false); }); + test("send MONITOR - with array (remove)", async () => { + const { client, server } = await mock(); + + client.monitor.add(["nick1", "nick2"]); + server.receive(); + + client.monitor.remove(["nick1", "nick2"]); + assertEquals(server.receive(), ["MONITOR - nick1,nick2"]); + assertEquals(client.state.monitorList.size, 0); + }); + test("send MONITOR L (list)", async () => { const { client, server } = await mock(); @@ -127,4 +138,25 @@ describe("plugins/monitor", (test) => { assertEquals(client.state.monitorLimit, 100); }); + + test("ignore ISUPPORT MONITOR without value", async () => { + const { client, server } = await mock(); + + server.send(":serverhost 005 me MONITOR :are supported by this server"); + await client.once("isupport:monitor"); + + assertEquals(client.state.monitorLimit, 0); + }); + + test("handle RPL_MONLIST with empty targets", async () => { + const { client, server } = await mock(); + + server.send([ + ":serverhost 732 me", + ":serverhost 733 me :End of MONITOR list", + ]); + + const msg = await client.once("monitor_list"); + assertEquals(msg.params, { nicks: [] }); + }); }); diff --git a/plugins/standard_replies_test.ts b/plugins/standard_replies_test.ts index 9e25347a..27f365f6 100644 --- a/plugins/standard_replies_test.ts +++ b/plugins/standard_replies_test.ts @@ -54,6 +54,34 @@ describe("plugins/standard_replies", (test) => { }); }); + test("emit 'fail' on FAIL with only command and code", async () => { + const { client, server } = await mock(); + + server.send(":server FAIL COMMAND CODE"); + const msg = await client.once("fail"); + + assertEquals(msg.params, { + command: "COMMAND", + code: "CODE", + context: [], + description: "", + }); + }); + + test("emit 'fail' on FAIL with no params", async () => { + const { client, server } = await mock(); + + server.send(":server FAIL"); + const msg = await client.once("fail"); + + assertEquals(msg.params, { + command: "", + code: "", + context: [], + description: "", + }); + }); + test("emit 'note' on NOTE", async () => { const { client, server } = await mock(); diff --git a/plugins/userhost_in_names_test.ts b/plugins/userhost_in_names_test.ts index da919223..af48e093 100644 --- a/plugins/userhost_in_names_test.ts +++ b/plugins/userhost_in_names_test.ts @@ -51,6 +51,21 @@ describe("plugins/userhost_in_names", (test) => { assertEquals(client.state.userhosts["#chan"], {}); }); + test("skip nick with ! but no @", async () => { + const { client, server } = await mock(); + + server.send([ + ":server 353 me = #chan :nick1!user1 nick2!user2@host2", + ":server 366 me #chan :End of /NAMES list", + ]); + + await client.once("names_reply"); + + assertEquals(client.state.userhosts["#chan"], { + nick2: { user: "user2", host: "host2" }, + }); + }); + test("accumulate multiple RPL_NAMREPLY lines", async () => { const { client, server } = await mock(); diff --git a/plugins/who_test.ts b/plugins/who_test.ts index b485b134..9b3e868d 100644 --- a/plugins/who_test.ts +++ b/plugins/who_test.ts @@ -99,6 +99,34 @@ describe("plugins/who", (test) => { assertEquals(msg.params.entries, []); }); + test("handle WHO reply without trailing param", async () => { + const { client, server } = await mock(); + + server.send([ + ":serverhost 352 me #channel user host server nick H", + ":serverhost 315 me #channel :End of /WHO list", + ]); + + const msg = await client.once("who_reply"); + + assertEquals(msg.params.entries[0].hopcount, undefined); + assertEquals(msg.params.entries[0].realname, undefined); + }); + + test("handle WHOX reply with unknown token", async () => { + const { client, server } = await mock(); + + server.send([ + ":serverhost 354 me unknown_token nick1 user1 host1", + ":serverhost 315 me unknown_token :End of /WHO list", + ]); + + const msg = await client.once("who_reply"); + + assertEquals(msg.params.target, "unknown_token"); + assertEquals(msg.params.entries.length, 1); + }); + test("handle WHO reply with hopcount only (no realname)", async () => { const { client, server } = await mock();