From 337acf96387794a9a513d0e9838ec8bdcc3bb908 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:31:45 +0200 Subject: [PATCH 1/9] tests: backfill integration tests for release-branch changes since 2019 Add pytest suites for behaviour changes made on the release branch that had no coverage in the harness (each module docstring names the commits): chanmodes/ channel modes +u (part/quit message suppression edge cases) and +M (moderate unauthenticated users) cap/ capability list, extended-join on every JOIN path, echo-message (plus edge cases) relay/ NOTICE nick@server rules, JOIN target limits (JOIN_TARGET), CPRIVMSG idle reset commands/ WHOWAS 0, WHOX %l field, PART, INFO, CONNECT 0, PRIVS defaults, remote STATS parameter forwarding features/ Boolean feature spellings (0/1), HIS_REMOTE gating, OPLEVELS/ZANNELS defaults, removed MAXIMUM_LINKS s2s/ parser robustness (bad numerics, END_OF_BURST token), GLINE reason/lifetime updates from servers username/ ident lookups only with a username mask, WebIRC username trust, STRICT_USERNAME digit-group and case rules iauth/ STATS iauth / iauthconf, asynchronous "? stats2", control-character handling in IAuth input config/ Include directive and lexer via "ircd -k" Harness changes: make_client(caps=...), an "oper" fixture, docker_exec / docker_cp_text helpers, split P10 handshake methods on P10Server (as on the feature branches), and a shared common.py (join/drain/whois and set_feature, which waits for the RPL_FEATURE reply instead of sleeping because ircu defers a client's commands once its flood penalty builds up). leaf2 now runs a non-forcing IAuth stub (iauth-test.pl) with ident lookups and a WebIRC port; the hub sets AUTH_TIMEOUT=3 and MAXCHANNELSPERUSER=40 and gives notulined.test.net a port. Six strict xfails document bugs found while writing the tests: the lexer has no FROM token, a missing or empty Include file breaks parsing (hang / syntax error), a self-including file aborts ircd, mo_info still uses a stale line offset, and CPRIVMSG/CNOTICE are not echoed. --- Dockerfile | 3 + docker-compose.yml | 1 + tests/README.md | 37 ++++ tests/cap/test_cap_edge_cases_main.py | 144 ++++++++++++ tests/cap/test_cap_list.py | 127 +++++++++++ tests/cap/test_echo_message.py | 94 ++++++++ tests/cap/test_extended_join.py | 194 +++++++++++++++++ tests/chanmodes/__init__.py | 0 tests/chanmodes/test_mode_M.py | 153 +++++++++++++ tests/chanmodes/test_mode_edge_cases.py | 241 +++++++++++++++++++++ tests/commands/__init__.py | 0 tests/commands/test_command_edge_cases.py | 156 +++++++++++++ tests/commands/test_connect.py | 57 +++++ tests/commands/test_info.py | 84 +++++++ tests/commands/test_part.py | 56 +++++ tests/commands/test_privs.py | 34 +++ tests/commands/test_stats_remote.py | 44 ++++ tests/commands/test_who_fields.py | 38 ++++ tests/commands/test_whowas.py | 56 +++++ tests/common.py | 108 +++++++++ tests/config/__init__.py | 0 tests/config/test_include.py | 177 +++++++++++++++ tests/conftest.py | 65 +++++- tests/docker/iauth-test.pl | 84 +++++++ tests/docker/ircd-hub.conf | 7 +- tests/docker/ircd-leaf1.conf | 1 + tests/docker/ircd-leaf2.conf | 18 +- tests/features/__init__.py | 0 tests/features/test_boolean_features.py | 99 +++++++++ tests/features/test_feature_edge_cases.py | 70 ++++++ tests/iauth/__init__.py | 0 tests/iauth/test_iauth_edge_cases.py | 71 ++++++ tests/iauth/test_iauth_stats.py | 98 +++++++++ tests/p10_server.py | 37 +++- tests/relay/__init__.py | 0 tests/relay/test_cprivmsg_idle.py | 52 +++++ tests/relay/test_directed_notice.py | 67 ++++++ tests/relay/test_join_target.py | 99 +++++++++ tests/relay/test_relay_edge_cases.py | 102 +++++++++ tests/s2s/__init__.py | 0 tests/s2s/test_gline_reason.py | 82 +++++++ tests/s2s/test_parse.py | 96 ++++++++ tests/s2s/test_s2s_edge_cases.py | 142 ++++++++++++ tests/username/__init__.py | 0 tests/username/test_ident_username.py | 142 ++++++++++++ tests/username/test_strict_digit_groups.py | 81 +++++++ tests/username/test_username_edge_cases.py | 69 ++++++ 47 files changed, 3277 insertions(+), 9 deletions(-) create mode 100644 tests/cap/test_cap_edge_cases_main.py create mode 100644 tests/cap/test_cap_list.py create mode 100644 tests/cap/test_echo_message.py create mode 100644 tests/cap/test_extended_join.py create mode 100644 tests/chanmodes/__init__.py create mode 100644 tests/chanmodes/test_mode_M.py create mode 100644 tests/chanmodes/test_mode_edge_cases.py create mode 100644 tests/commands/__init__.py create mode 100644 tests/commands/test_command_edge_cases.py create mode 100644 tests/commands/test_connect.py create mode 100644 tests/commands/test_info.py create mode 100644 tests/commands/test_part.py create mode 100644 tests/commands/test_privs.py create mode 100644 tests/commands/test_stats_remote.py create mode 100644 tests/commands/test_who_fields.py create mode 100644 tests/commands/test_whowas.py create mode 100644 tests/common.py create mode 100644 tests/config/__init__.py create mode 100644 tests/config/test_include.py create mode 100755 tests/docker/iauth-test.pl create mode 100644 tests/features/__init__.py create mode 100644 tests/features/test_boolean_features.py create mode 100644 tests/features/test_feature_edge_cases.py create mode 100644 tests/iauth/__init__.py create mode 100644 tests/iauth/test_iauth_edge_cases.py create mode 100644 tests/iauth/test_iauth_stats.py create mode 100644 tests/relay/__init__.py create mode 100644 tests/relay/test_cprivmsg_idle.py create mode 100644 tests/relay/test_directed_notice.py create mode 100644 tests/relay/test_join_target.py create mode 100644 tests/relay/test_relay_edge_cases.py create mode 100644 tests/s2s/__init__.py create mode 100644 tests/s2s/test_gline_reason.py create mode 100644 tests/s2s/test_parse.py create mode 100644 tests/s2s/test_s2s_edge_cases.py create mode 100644 tests/username/__init__.py create mode 100644 tests/username/test_ident_username.py create mode 100644 tests/username/test_strict_digit_groups.py create mode 100644 tests/username/test_username_edge_cases.py diff --git a/Dockerfile b/Dockerfile index e1f6105e..757df49e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,6 +115,9 @@ RUN touch /opt/ircu/lib/ircd.motd && chown ircu:ircu /opt/ircu/lib/ircd.motd COPY tests/docker/iauth-tilded.pl /opt/ircu/bin/iauth-tilded.pl RUN chmod +x /opt/ircu/bin/iauth-tilded.pl && chown ircu:ircu /opt/ircu/bin/iauth-tilded.pl +COPY tests/docker/iauth-test.pl /opt/ircu/bin/iauth-test.pl +RUN chmod +x /opt/ircu/bin/iauth-test.pl && chown ircu:ircu /opt/ircu/bin/iauth-test.pl + COPY tests/docker/ircd-entrypoint.sh /opt/ircu/lib/ircd-entrypoint.sh RUN chmod 755 /opt/ircu/lib/ircd-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 158097c4..c7a7ff87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: ports: - "6669:6669" - "4402:4402" + - "6691:6691" networks: ircu-test-net: ipv4_address: 10.55.0.12 diff --git a/tests/README.md b/tests/README.md index 14df56e8..70e3445d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -102,6 +102,33 @@ conftest.py # pytest fixtures (ircd_hub, ircd_network, make_client) - **test_fix.py** — focused tests that reproduce the bug or verify the feature claimed by the PR. These fail on the base branch and pass with the PR applied. - **test_edge_cases.py** — adversarial tests that exercise boundary conditions, invalid inputs, and feature interactions. Tests that depend on the PR feature use `pytest.skip()` when it's not available. +## Behaviour suites (main-branch changes since 2019) + +Besides the per-PR directories, these suites pin down behaviour changes made +directly on the release branch (each module docstring names the commits): + +| Path | What it covers | +|------|----------------| +| `chanmodes/` | channel modes +P (no part/quit messages) and +M (moderate unauthed users) | +| `cap/test_cap_list.py`, `cap/test_extended_join.py`, `cap/test_echo_message.py`, `cap/test_cap_edge_cases_main.py` | capability list, extended-join on every JOIN path, echo-message | +| `relay/` | `NOTICE nick@server`, JOIN target limits (`JOIN_TARGET`), CPRIVMSG idle reset | +| `commands/` | WHOWAS `0`, WHOX `%l`, PART, INFO, CONNECT `0`, PRIVS, remote STATS | +| `features/` | Boolean features (`0`/`1`, spellings), HIS_REMOTE, defaults, removed features | +| `s2s/` | server parser robustness (`END_OF_BURST`, bad numerics), GLINE reason/lifetime updates | +| `username/` | ident / WebIRC username handling, STRICT_USERNAME rules | +| `iauth/` | `/STATS iauth` and `/STATS iauthconf`, asynchronous `? stats2`, IAuth line parsing | +| `config/` | `Include` and the configuration lexer via `ircd -k` inside the hub container | + +Shared helpers for these live in `common.py` (`join`, `drain`, `whois`, +`set_feature`, ...). `set_feature()` exists because `SET` only answers when +the value changes and ircu defers a client's commands once its flood penalty +builds up, so "SET + sleep" is racy. + +Strict `xfail` markers in `config/test_include.py` document known ircd bugs: +`Include from "file"` is a syntax error (the lexer has no `from` +token), a missing include file makes `ircd -k` hang, and a self-including +file aborts it. + ## Docker Topology Three ircd servers form a test network: @@ -112,6 +139,12 @@ Three ircd servers form a test network: | ircd-leaf1 | leaf1.test.net | 6668 | 4401 | 2 | | ircd-leaf2 | leaf2.test.net | 6669 | 4402 | 3 | +leaf2 differs from the others: ident lookups are on (`Client { username = "*" }`), +it runs the non-forcing `docker/iauth-test.pl` (policy `ARUS`, supports `? config` / +`? stats2`) instead of `iauth-tilded.pl`, and port 6691 is a WebIRC port +(`WEBIRC webircpass ...`). The hub Connect block `notulined.test.net` points at +port 4499 where nothing listens (CONNECT tests). + | ircd-tls-hub | tls-hub.test.net | 16677 / 16697 | 14440 / 14441 | 10 | | ircd-tls-leaf | tls-leaf.test.net | 16678 / 16680 | 14411 / 14412 | 11 | @@ -227,7 +260,11 @@ The P10 server handles the full handshake (PASS, SERVER, burst, EB/EA), auto-res ```python client = await make_client("mynick") client = await make_client("mynick", host="127.0.0.1", port=6668) + client = await make_client("mynick", caps=["extended-join"]) # negotiates CAPs first ``` +- **`oper`** (function) — a registered global operator (`testop`) on the hub +- **`ulined_server`** (function) — U:lined fake P10 server (`services.test.net`) linked to the hub +- `docker_exec()` / `docker_cp_text()` — run commands / write files inside a test container ## Writing Tests for a New PR diff --git a/tests/cap/test_cap_edge_cases_main.py b/tests/cap/test_cap_edge_cases_main.py new file mode 100644 index 00000000..1d0bf7b5 --- /dev/null +++ b/tests/cap/test_cap_edge_cases_main.py @@ -0,0 +1,144 @@ +"""Edge cases for extended-join and echo-message (commits 4db844d, dfd9afa, +cd6e2e4).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import drain, join, sender_nick, wait_for_join + +pytestmark = pytest.mark.single_server + + +async def test_extended_join_reveal_by_voice(make_client): + """Giving +v to a hidden (+D) member reveals it with an extended JOIN.""" + chan = "#eje_voice" + op = await make_client("eje_op1", caps=["extended-join"]) + await join(op, chan) + await op.send(f"MODE {chan} +D") + await op.wait_for("MODE") + await drain(op) + hidden = await make_client("eje_hid1", realname="Hidden Voice") + await join(hidden, chan) + await op.assert_no_message("JOIN", timeout=1.0) + await op.send(f"MODE {chan} +v eje_hid1") + reveal = await wait_for_join(op, chan, "eje_hid1") + assert reveal.params == [chan, "*", "Hidden Voice"], reveal.raw + mode = await op.wait_for("MODE", timeout=5.0) + assert mode.params[0].lower() == chan and "v" in mode.params[1] + + +async def test_extended_join_account_with_hidden_host(make_client, ulined_server): + """Account + hidden host: JOIN prefix uses the hidden host, param the account.""" + chan = "#eje_hidden" + obs = await make_client("eje_obs2", caps=["extended-join"]) + await join(obs, chan) + await drain(obs) + joiner = await make_client("eje_join2", realname="Hidden Two") + numnick = await ulined_server.wait_for_user("eje_join2") + await ulined_server.send_account(numnick, "HidTwo") + await asyncio.sleep(0.3) + await joiner.send("MODE eje_join2 +x") + await joiner.wait_for("396", timeout=5.0) + await join(joiner, chan) + msg = await wait_for_join(obs, chan, "eje_join2") + assert msg.params == [chan, "HidTwo", "Hidden Two"], msg.raw + assert msg.prefix.endswith("@HidTwo.users.undernet.org"), msg.raw + + +async def test_extended_join_after_cap_removed(make_client): + """CAP REQ :-extended-join switches the client back to plain JOINs.""" + chan = "#eje_remove" + obs = await make_client("eje_obs3", caps=["extended-join"]) + await join(obs, chan) + await obs.send("CAP REQ :-extended-join") + ack = await obs.wait_for("CAP", timeout=5.0) + assert ack.params[1] == "ACK" and "-extended-join" in ack.params[-1] + await drain(obs) + joiner = await make_client("eje_join3") + await join(joiner, chan) + msg = await wait_for_join(obs, chan, "eje_join3") + assert len(msg.params) == 1, msg.raw + + +async def test_extended_join_realname_with_spaces_and_colon(make_client): + chan = "#eje_realname" + obs = await make_client("eje_obs4", caps=["extended-join"]) + await join(obs, chan) + await drain(obs) + joiner = await make_client("eje_join4", realname="Real: Name with spaces") + await join(joiner, chan) + msg = await wait_for_join(obs, chan, "eje_join4") + assert msg.params[2] == "Real: Name with spaces", msg.raw + + +async def test_echo_message_to_self(make_client): + """PRIVMSG to one's own nick: delivery plus echo => two copies.""" + client = await make_client("ech_self1", caps=["echo-message"]) + await client.send("PRIVMSG ech_self1 :talking to myself") + first = await client.wait_for_user_msg("PRIVMSG", timeout=5.0) + second = await client.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert first.params[-1] == second.params[-1] == "talking to myself" + await client.assert_no_message("PRIVMSG", timeout=0.5) + + +async def test_echo_message_wallchops(make_client): + chan = "#ech_wallchops" + op = await make_client("ech_op2", caps=["echo-message"]) + peer = await make_client("ech_peer2") + await join(op, chan) + await join(peer, chan) + await asyncio.sleep(0.2) + await drain(op) + await op.send(f"WALLCHOPS {chan} :ops only") + # WALLCHOPS is delivered (and echoed) as NOTICE @#chan :@ + echo = await op.wait_for_user_msg("NOTICE", timeout=5.0) + assert sender_nick(echo) == "ech_op2", echo.raw + assert echo.params == [f"@{chan}", "@ ops only"], echo.raw + + +async def test_echo_message_not_sent_when_blocked(make_client): + """A message the server refuses (+n, not on channel) is not echoed.""" + chan = "#ech_blocked" + op = await make_client("ech_op3") + await join(op, chan) + await op.send(f"MODE {chan} +n") + await op.wait_for("MODE") + outsider = await make_client("ech_out3", caps=["echo-message"]) + await outsider.send(f"PRIVMSG {chan} :from outside") + err = await outsider.wait_for("404", timeout=5.0) + assert err.params[1].lower() == chan + await outsider.assert_no_message("PRIVMSG", timeout=1.0) + + +async def test_echo_message_multi_target(make_client): + """PRIVMSG a,b: one echo per delivered target.""" + sender = await make_client("ech_multi4", caps=["echo-message"]) + t1 = await make_client("ech_t4a") + t2 = await make_client("ech_t4b") + await sender.send("PRIVMSG ech_t4a,ech_t4b :both of you") + echoes = [await sender.wait_for_user_msg("PRIVMSG", timeout=5.0) for _ in range(2)] + assert sorted(e.params[0] for e in echoes) == ["ech_t4a", "ech_t4b"] + for t in (t1, t2): + got = await t.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert got.params[-1] == "both of you" + + +@pytest.mark.xfail( + reason="CPRIVMSG/CNOTICE (whisper) never echo the message, unlike PRIVMSG/NOTICE", + strict=True, +) +async def test_echo_message_cprivmsg(make_client): + chan = "#ech_cprivmsg" + op = await make_client("ech_op5", caps=["echo-message"]) + peer = await make_client("ech_peer5") + await join(op, chan) + await join(peer, chan) + await asyncio.sleep(0.2) + await drain(op) + await op.send(f"CPRIVMSG ech_peer5 {chan} :whispered") + await peer.wait_for_user_msg("PRIVMSG", timeout=5.0) + echo = await op.wait_for_user_msg("PRIVMSG", timeout=3.0) + assert echo.params == ["ech_peer5", "whispered"] diff --git a/tests/cap/test_cap_list.py b/tests/cap/test_cap_list.py new file mode 100644 index 00000000..a6419825 --- /dev/null +++ b/tests/cap/test_cap_list.py @@ -0,0 +1,127 @@ +"""IRCv3 capability list (commit 4db844d and later additions). + +The six capabilities introduced by 4db844d/f78fe06 are always advertised +(plus whatever later features added); a capability disabled through its +FEAT_CAP_* feature disappears from CAP LS and is refused on CAP REQ; sasl +stays hidden while no SASL agent is linked. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import set_feature +from irc_client import IRCClient + +pytestmark = pytest.mark.single_server + +EXPECTED_CAPS = { + "account-notify", + "away-notify", + "chghost", + "echo-message", + "extended-join", + "invite-notify", +} + + +async def _cap_ls(client: IRCClient) -> set[str]: + await client.send("CAP LS") + names: set[str] = set() + while True: + msg = await client.wait_for("CAP", timeout=5.0) + assert msg.params[1] == "LS", msg.raw + names.update(t.split("=", 1)[0].lstrip("~=") for t in msg.params[-1].split()) + if len(msg.params) < 4 or msg.params[2] != "*": + return names + + +async def test_cap_ls_lists_builtin_caps(ircd_hub): + client = IRCClient() + await client.connect(ircd_hub["host"], ircd_hub["port"]) + try: + names = await _cap_ls(client) + assert EXPECTED_CAPS <= names, names + assert "sasl" not in names, names # CAPFL_UNAVAILABLE without an agent + finally: + await client.send("CAP END") + await client.send("QUIT :done") + await client.disconnect() + + +async def test_cap_req_acks_every_builtin_cap(ircd_hub): + client = IRCClient() + await client.connect(ircd_hub["host"], ircd_hub["port"]) + try: + acked = await client.negotiate_cap(sorted(EXPECTED_CAPS)) + assert set(acked) == EXPECTED_CAPS + await client.register("capall1", "testuser", "cap all") + await client.send("CAP LIST") + listing = await client.wait_for("CAP", timeout=5.0) + assert listing.params[1] == "LIST" + assert set(listing.params[-1].split()) == EXPECTED_CAPS + finally: + await client.send("QUIT :done") + await client.disconnect() + + +async def test_cap_ls_works_after_registration(make_client): + """CAP LS after registration answers without suspending anything (2e93875).""" + client = await make_client("capreg1") + names = await _cap_ls(client) + assert EXPECTED_CAPS <= names + await client.send("CAP END") # ignored once registered + await client.send("PING :still-here") + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "still-here" + + +async def test_cap_ls_before_registration_waits_for_cap_end(ircd_hub): + """A pending CAP negotiation holds registration until CAP END (2e93875).""" + client = IRCClient() + await client.connect(ircd_hub["host"], ircd_hub["port"]) + try: + await _cap_ls(client) + await client.send("NICK capwait1") + await client.send("USER testuser 0 * :waiting") + with pytest.raises((asyncio.TimeoutError, TimeoutError)): + await client.wait_for("001", timeout=1.5) + await client.send("CAP END") + welcome = await client.wait_for("001", timeout=10.0) + assert welcome.command == "001" + finally: + await client.send("QUIT :done") + await client.disconnect() + + +async def test_feature_disables_cap(ircd_hub, oper): + """FEAT_CAP_ECHOMESSAGE=FALSE hides echo-message and NAKs a REQ for it.""" + await set_feature(oper, "CAP_ECHOMESSAGE", "FALSE") + client = IRCClient() + await client.connect(ircd_hub["host"], ircd_hub["port"]) + try: + names = await _cap_ls(client) + assert "echo-message" not in names + assert EXPECTED_CAPS - {"echo-message"} <= names + await client.send("CAP REQ :echo-message") + nak = await client.wait_for("CAP", timeout=5.0) + assert nak.params[1] == "NAK", nak.raw + await client.send("CAP REQ :away-notify") + ack = await client.wait_for("CAP", timeout=5.0) + assert ack.params[1] == "ACK", ack.raw + finally: + await set_feature(oper, "CAP_ECHOMESSAGE", "TRUE") + await client.send("CAP END") + await client.send("QUIT :done") + await client.disconnect() + + client2 = IRCClient() + await client2.connect(ircd_hub["host"], ircd_hub["port"]) + try: + assert "echo-message" in await _cap_ls(client2) + finally: + await client2.send("CAP END") + await client2.send("QUIT :done") + await client2.disconnect() diff --git a/tests/cap/test_echo_message.py b/tests/cap/test_echo_message.py new file mode 100644 index 00000000..ca8f3867 --- /dev/null +++ b/tests/cap/test_echo_message.py @@ -0,0 +1,94 @@ +"""echo-message capability (commit 4db844d). + +A client that negotiated echo-message receives a copy of every PRIVMSG and +NOTICE it sends (to users, channels, and nick@server targets); a client +without it receives nothing back. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import drain, join, sender_nick + +pytestmark = pytest.mark.single_server + + +async def test_privmsg_to_user_is_echoed(make_client): + sender = await make_client("echo1", caps=["echo-message"]) + target = await make_client("echot1") + await sender.send("PRIVMSG echot1 :hello there") + echo = await sender.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert sender_nick(echo) == "echo1" + assert echo.params == ["echot1", "hello there"], echo.raw + delivered = await target.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert delivered.params[-1] == "hello there" + + +async def test_notice_to_user_is_echoed(make_client): + sender = await make_client("echo2", caps=["echo-message"]) + target = await make_client("echot2") + await sender.send("NOTICE echot2 :notice me") + echo = await sender.wait_for_user_msg("NOTICE", timeout=5.0) + assert sender_nick(echo) == "echo2" + assert echo.params == ["echot2", "notice me"], echo.raw + delivered = await target.wait_for_user_msg("NOTICE", timeout=5.0) + assert delivered.params[-1] == "notice me" + + +async def test_channel_messages_are_echoed(make_client): + chan = "#echo_chan" + sender = await make_client("echo3", caps=["echo-message"]) + peer = await make_client("echop3") + await join(sender, chan) + await join(peer, chan) + await asyncio.sleep(0.3) + await drain(sender) + await drain(peer) + + await sender.send(f"PRIVMSG {chan} :chan hello") + echo = await sender.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert sender_nick(echo) == "echo3" and echo.params == [chan, "chan hello"] + got = await peer.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert got.params[-1] == "chan hello" + + await sender.send(f"NOTICE {chan} :chan notice") + echo = await sender.wait_for_user_msg("NOTICE", timeout=5.0) + assert echo.params == [chan, "chan notice"] + + +async def test_no_echo_without_cap(make_client): + chan = "#echo_nocap" + sender = await make_client("echo4") + target = await make_client("echot4") + await join(sender, chan) + await join(target, chan) + await asyncio.sleep(0.3) + await drain(sender) + await drain(target) + + await sender.send("PRIVMSG echot4 :direct") + await sender.send(f"PRIVMSG {chan} :channel") + await sender.send("NOTICE echot4 :direct notice") + await target.wait_for_user_msg("PRIVMSG", timeout=5.0) + await sender.assert_no_message("PRIVMSG", timeout=1.5) + await sender.assert_no_message("NOTICE", timeout=0.5) + + +async def test_directed_message_to_service_is_echoed(make_client, ulined_server): + """nick@server targets on a service server are echoed too (relay_directed_*).""" + await ulined_server.introduce_user("EchoSvc", modes="+ik") + sender = await make_client("echo5", caps=["echo-message"]) + await sender.send("PRIVMSG EchoSvc@services.test.net :svc msg") + echo = await sender.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert echo.params == ["EchoSvc@services.test.net", "svc msg"], echo.raw + line = await ulined_server.wait_for_token("P", timeout=5.0) + assert "EchoSvc@services.test.net :svc msg" in line + + await sender.send("NOTICE EchoSvc@services.test.net :svc notice") + echo = await sender.wait_for_user_msg("NOTICE", timeout=5.0) + assert echo.params == ["EchoSvc@services.test.net", "svc notice"], echo.raw + line = await ulined_server.wait_for_token("O", timeout=5.0) + assert "EchoSvc@services.test.net :svc notice" in line diff --git a/tests/cap/test_extended_join.py b/tests/cap/test_extended_join.py new file mode 100644 index 00000000..ac121b6e --- /dev/null +++ b/tests/cap/test_extended_join.py @@ -0,0 +1,194 @@ +"""extended-join is honoured on every JOIN path (commits 4db844d, dfd9afa, +cd6e2e4). + +Clients with extended-join receive ``JOIN :``; +clients without it receive the classic single-parameter JOIN. Covered +paths: normal join, own join, delayed-join reveal (+D), kick of a hidden +member, server burst, remote join, and the host-hiding re-JOIN. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from common import drain, join, sender_nick, wait_for_join + +pytestmark = pytest.mark.single_server + + +def _assert_extended(msg, chan, account, realname): + assert msg.params[0].lower() == chan.lower(), msg.raw + assert len(msg.params) == 3, f"expected extended JOIN, got {msg.raw}" + assert msg.params[1] == account, msg.raw + assert msg.params[2] == realname, msg.raw + + +def _assert_plain(msg, chan): + assert msg.params[0].lower() == chan.lower(), msg.raw + assert len(msg.params) == 1, f"expected plain JOIN, got {msg.raw}" + + +async def test_join_extended_vs_plain(make_client): + chan = "#ej_basic" + ext = await make_client("ejobs1", caps=["extended-join"]) + plain = await make_client("ejplain1") + await join(ext, chan) + await join(plain, chan) + await wait_for_join(ext, chan, "ejplain1") + await drain(ext) + await drain(plain) + + joiner = await make_client("ejjoin1", realname="Joiner One") + await join(joiner, chan) + _assert_extended(await wait_for_join(ext, chan, "ejjoin1"), chan, "*", "Joiner One") + _assert_plain(await wait_for_join(plain, chan, "ejjoin1"), chan) + + +async def test_own_join_is_extended(make_client): + ext = await make_client("ejself2", caps=["extended-join"], realname="Self Two") + msg = await join(ext, "#ej_self") + _assert_extended(msg, "#ej_self", "*", "Self Two") + + +async def test_join_carries_account(make_client, ulined_server): + chan = "#ej_acct" + ext = await make_client("ejobs3", caps=["extended-join"]) + await join(ext, chan) + await drain(ext) + + joiner = await make_client("ejacct3", realname="Account Three") + numnick = await ulined_server.wait_for_user("ejacct3") + await ulined_server.send_account(numnick, "AcctThree") + await asyncio.sleep(0.4) + await join(joiner, chan) + _assert_extended(await wait_for_join(ext, chan, "ejacct3"), chan, "AcctThree", "Account Three") + + +async def test_delayed_join_reveal_is_extended(make_client): + """RevealDelayedJoin() sends extended/plain JOIN per capability.""" + chan = "#ej_delay" + op = await make_client("ejop4") + await join(op, chan) + await op.send(f"MODE {chan} +D") + await op.wait_for("MODE") + ext = await make_client("ejobs4", caps=["extended-join"]) + plain = await make_client("ejplain4") + await join(ext, chan) + await join(plain, chan) + await asyncio.sleep(0.3) + await drain(ext) + await drain(plain) + + hidden = await make_client("ejhid4", realname="Hidden Four") + await join(hidden, chan) + # Delayed: nobody sees the join yet. + await ext.assert_no_message("JOIN", timeout=1.0) + await hidden.send(f"PRIVMSG {chan} :reveal me") + _assert_extended(await wait_for_join(ext, chan, "ejhid4"), chan, "*", "Hidden Four") + _assert_plain(await wait_for_join(plain, chan, "ejhid4"), chan) + + +async def test_delayed_join_reveal_sends_away(make_client): + """An away user's reveal is followed by AWAY for away-notify clients (cd6e2e4).""" + chan = "#ej_delay_away" + op = await make_client("ejop5") + await join(op, chan) + await op.send(f"MODE {chan} +D") + await op.wait_for("MODE") + obs = await make_client("ejobs5", caps=["extended-join", "away-notify"]) + nocap = await make_client("ejnocap5") + await join(obs, chan) + await join(nocap, chan) + await asyncio.sleep(0.3) + await drain(obs) + await drain(nocap) + + hidden = await make_client("ejhid5", realname="Hidden Five") + await hidden.send("AWAY :gone") + await hidden.wait_for("306") + await join(hidden, chan) + await hidden.send(f"PRIVMSG {chan} :reveal me") + _assert_extended(await wait_for_join(obs, chan, "ejhid5"), chan, "*", "Hidden Five") + away = await obs.wait_for_user_msg("AWAY", timeout=5.0) + assert sender_nick(away) == "ejhid5" and away.params[-1] == "gone" + await wait_for_join(nocap, chan, "ejhid5") + await nocap.assert_no_message("AWAY", timeout=1.0) + + +async def test_kick_of_hidden_member_shows_extended_join_to_kicker(make_client): + """m_kick reveals a delayed member to the kicker with sendjointo_one().""" + chan = "#ej_kick" + op = await make_client("ejop6", caps=["extended-join"]) + await join(op, chan) + await op.send(f"MODE {chan} +D") + await op.wait_for("MODE") + await drain(op) + hidden = await make_client("ejhid6", realname="Hidden Six") + await join(hidden, chan) + await op.assert_no_message("JOIN", timeout=1.0) + + await op.send(f"KICK {chan} ejhid6 :out") + _assert_extended(await wait_for_join(op, chan, "ejhid6"), chan, "*", "Hidden Six") + kick = await op.wait_for_user_msg("KICK", timeout=5.0) + assert kick.params[1] == "ejhid6" + + +async def test_burst_join_is_extended(make_client, ulined_server): + """Members added by a server BURST get extended JOINs (ms_burst).""" + chan = "#ej_burst" + ext = await make_client("ejobs7", caps=["extended-join"]) + plain = await make_client("ejplain7") + await join(ext, chan) + await join(plain, chan) + await asyncio.sleep(0.3) + await drain(ext) + await drain(plain) + + numnick = await ulined_server.introduce_user( + "ejburst7", modes="+ir BurstAcct", realname="Burst Seven" + ) + await ulined_server._send(f"{ulined_server.server_numnick} B {chan} {int(time.time())} {numnick}") + _assert_extended(await wait_for_join(ext, chan, "ejburst7"), chan, "BurstAcct", "Burst Seven") + _assert_plain(await wait_for_join(plain, chan, "ejburst7"), chan) + + +async def test_host_hiding_rejoin_is_extended(make_client, ulined_server): + """hide_hostmask() re-JOINs non-chghost peers with sendjointo_channel_butserv().""" + chan = "#ej_hide" + ext = await make_client("ejobs8", caps=["extended-join"]) + await join(ext, chan) + subject = await make_client("ejhide8", realname="Hide Eight") + await join(subject, chan) + await wait_for_join(ext, chan, "ejhide8") + await drain(ext) + + numnick = await ulined_server.wait_for_user("ejhide8") + await ulined_server.send_account(numnick, "HideAcct") + await asyncio.sleep(0.3) + await subject.send("MODE ejhide8 +x") + quit_msg = await ext.wait_for_user_msg("QUIT", timeout=5.0) + assert sender_nick(quit_msg) == "ejhide8" and quit_msg.params[-1] == "Registered" + rejoin = await wait_for_join(ext, chan, "ejhide8") + _assert_extended(rejoin, chan, "HideAcct", "Hide Eight") + assert rejoin.prefix.endswith("@HideAcct.users.undernet.org"), rejoin.raw + + +@pytest.mark.multi_server +async def test_remote_join_is_extended(ircd_network, make_client): + chan = "#ej_remote" + hub, leaf = ircd_network["hub"], ircd_network["leaf1"] + ext = await make_client("ejobs9", caps=["extended-join"]) + plain = await make_client("ejplain9") + await join(ext, chan) + await join(plain, chan) + await asyncio.sleep(0.3) + await drain(ext) + await drain(plain) + + remote = await make_client("ejrem9", host=leaf["host"], port=leaf["port"], realname="Remote Nine") + await join(remote, chan) + _assert_extended(await wait_for_join(ext, chan, "ejrem9"), chan, "*", "Remote Nine") + _assert_plain(await wait_for_join(plain, chan, "ejrem9"), chan) diff --git a/tests/chanmodes/__init__.py b/tests/chanmodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/chanmodes/test_mode_M.py b/tests/chanmodes/test_mode_M.py new file mode 100644 index 00000000..095beb65 --- /dev/null +++ b/tests/chanmodes/test_mode_M.py @@ -0,0 +1,153 @@ +"""Channel mode +M: moderate unauthenticated users (commit 47af138, #5). + +On a +M channel, members without an account cannot speak or change nick +unless voiced/opped; users with an account (set via services ACCOUNT) can. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import chan_modes, drain, join, sender_nick, wait_for_join + +pytestmark = pytest.mark.single_server + + +async def _setup(make_client, chan, nicks=("mop", "mplain")): + op = await make_client(nicks[0]) + plain = await make_client(nicks[1]) + await join(op, chan) + await op.send(f"MODE {chan} +M") + await op.wait_for("MODE") + await join(plain, chan) + await wait_for_join(op, chan, nicks[1]) + await drain(op) + await drain(plain) + return op, plain + + +async def test_mode_M_shown(make_client): + op = await make_client("mshow1") + chan = "#modem_show" + await join(op, chan) + await op.send(f"MODE {chan} +M") + echo = await op.wait_for("MODE") + assert "M" in echo.params[1], echo.raw + assert "M" in await chan_modes(op, chan) + + +async def test_unregistered_member_cannot_speak(make_client): + """PRIVMSG from an account-less member is refused with ERR_CANNOTSENDTOCHAN.""" + chan = "#modem_speak" + op, plain = await _setup(make_client, chan, ("mop2", "mplain2")) + await plain.send(f"PRIVMSG {chan} :hello?") + err = await plain.wait_for("404", timeout=5.0) + assert err.params[1].lower() == chan + await op.assert_no_message("PRIVMSG", timeout=1.0) + + +async def test_voiced_unregistered_member_can_speak(make_client): + """+v overrides +M for an account-less member.""" + chan = "#modem_voice" + op, plain = await _setup(make_client, chan, ("mop3", "mplain3")) + await op.send(f"MODE {chan} +v mplain3") + await op.wait_for("MODE") + await plain.wait_for("MODE") + await plain.send(f"PRIVMSG {chan} :voiced hello") + msg = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert sender_nick(msg) == "mplain3" and msg.params[-1] == "voiced hello" + + +async def test_unregistered_member_cannot_change_nick(make_client): + """Nick changes are blocked (ERR_BANNICKCHANGE) for account-less members of +M.""" + chan = "#modem_nick" + op, plain = await _setup(make_client, chan, ("mop4", "mplain4")) + await plain.send("NICK mplain4b") + err = await plain.wait_for("437", timeout=5.0) + assert err.params[1].lower() == chan, err.raw + await op.assert_no_message("NICK", timeout=1.0) + + await op.send(f"MODE {chan} +v mplain4") + await plain.wait_for("MODE") + await plain.send("NICK mplain4c") + nick = await plain.wait_for("NICK", timeout=5.0) + assert nick.params[0] == "mplain4c" + + +async def test_registered_member_can_speak(make_client, ulined_server): + """A member with an account (services ACCOUNT) is not moderated by +M.""" + chan = "#modem_acct" + op, acct = await _setup(make_client, chan, ("mop5", "macct5")) + numnick = await ulined_server.wait_for_user("macct5") + await ulined_server.send_account(numnick, "AcctFive") + await asyncio.sleep(0.4) + await drain(acct) + + await acct.send(f"PRIVMSG {chan} :registered hello") + msg = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert sender_nick(msg) == "macct5" and msg.params[-1] == "registered hello" + + await acct.send("NICK macct5b") + nick = await acct.wait_for("NICK", timeout=5.0) + assert nick.params[0] == "macct5b" + + +async def test_unregistered_non_member_cannot_speak(make_client, ulined_server): + """Without +n, +M still blocks account-less non-members but not registered ones.""" + chan = "#modem_ext" + op = await make_client("mop6") + await join(op, chan) + await op.send(f"MODE {chan} +M-n") + await op.wait_for("MODE") + await drain(op) + + outsider = await make_client("mout6") + await outsider.send(f"PRIVMSG {chan} :outside") + err = await outsider.wait_for("404", timeout=5.0) + assert err.params[1].lower() == chan + await op.assert_no_message("PRIVMSG", timeout=1.0) + + numnick = await ulined_server.wait_for_user("mout6") + await ulined_server.send_account(numnick, "AcctOut") + await asyncio.sleep(0.4) + await outsider.send(f"PRIVMSG {chan} :outside registered") + msg = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert msg.params[-1] == "outside registered" + + +async def test_unsetting_M_allows_speaking(make_client): + chan = "#modem_unset" + op, plain = await _setup(make_client, chan, ("mop7", "mplain7")) + await op.send(f"MODE {chan} -M") + await op.wait_for("MODE") + await plain.wait_for("MODE") + await plain.send(f"PRIVMSG {chan} :free again") + msg = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert msg.params[-1] == "free again" + + +async def test_clearmode_clears_M(make_client, oper): + op = await make_client("mop8") + chan = "#modem_clear" + await join(op, chan) + await op.send(f"MODE {chan} +M") + await op.wait_for("MODE") + await oper.send(f"CLEARMODE {chan} M") + await op.wait_for("MODE", timeout=5.0) + assert "M" not in await chan_modes(op, chan) + + +async def test_isupport_advertises_M(make_client): + """RPL_ISUPPORT CHANMODES lists +M among the argument-less modes (e3bf7e9).""" + client = await make_client("msup9") + chanmodes = None + for msg in client.received_messages: + if msg.command == "005": + for p in msg.params: + if p.startswith("CHANMODES="): + chanmodes = p.split("=", 1)[1] + assert chanmodes, "no CHANMODES token in 005" + groups = chanmodes.split(",") + assert len(groups) == 4 and "M" in groups[3], chanmodes diff --git a/tests/chanmodes/test_mode_edge_cases.py b/tests/chanmodes/test_mode_edge_cases.py new file mode 100644 index 00000000..7b592ea6 --- /dev/null +++ b/tests/chanmodes/test_mode_edge_cases.py @@ -0,0 +1,241 @@ +"""Edge cases for channel modes +u (no part/quit messages; introduced as +u by +68727a8/c1bd976 and renamed to +u by PR #68) and +M (47af138). + +The basic +u behaviour (set/unset, part/quit suppression, remote part) is +covered by pr68_chanmode_u/; these are the remaining angles. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import chan_modes, drain, join, sender_nick, wait_for_join + + +@pytest.mark.single_server +async def test_u_hides_chanop_part_message_too(make_client): + """+u applies to everybody on the channel, including its operators.""" + op = await make_client("pe_op2") + member = await make_client("pe_mem2") + chan = "#modeu_oppart" + await join(op, chan) + await op.send(f"MODE {chan} +u") + await op.wait_for("MODE") + await join(member, chan) + await drain(member) + await op.send(f"PART {chan} :op leaving") + part = await member.wait_for_user_msg("PART", timeout=5.0) + assert sender_nick(part) == "pe_op2" + assert len(part.params) == 1 or part.params[-1] == "", part.raw + + +@pytest.mark.single_server +async def test_u_on_local_channel(make_client): + """Local (&) channels support +u as well.""" + op = await make_client("pe_op3") + member = await make_client("pe_mem3") + chan = "&modeu_local" + await join(op, chan) + await op.send(f"MODE {chan} +u") + echo = await op.wait_for("MODE") + assert "u" in echo.params[1], echo.raw + await join(member, chan) + await drain(op) + await member.send(f"PART {chan} :local bye") + part = await op.wait_for_user_msg("PART", timeout=5.0) + assert len(part.params) == 1 or part.params[-1] == "", part.raw + + +@pytest.mark.single_server +async def test_u_with_empty_part_message(make_client): + """A PART without a comment on +u stays a bare PART (no empty trailing).""" + op = await make_client("pe_op4") + member = await make_client("pe_mem4") + chan = "#modeu_empty" + await join(op, chan) + await op.send(f"MODE {chan} +u") + await op.wait_for("MODE") + await join(member, chan) + await drain(op) + await member.send(f"PART {chan}") + part = await op.wait_for_user_msg("PART", timeout=5.0) + assert part.params[0].lower() == chan + assert len(part.params) == 1 or part.params[-1] == "", part.raw + + +@pytest.mark.multi_server +async def test_u_quit_rewritten_across_servers(ircd_network, make_client): + leaf = ircd_network["leaf1"] + op = await make_client("pe_op6") + chan = "#modeu_s2s_quit" + await join(op, chan) + await op.send(f"MODE {chan} +u") + await op.wait_for("MODE") + remote = await make_client("pe_rem6", host=leaf["host"], port=leaf["port"]) + await join(remote, chan) + await wait_for_join(op, chan, "pe_rem6") + await asyncio.sleep(0.3) + await drain(op) + await remote.send("QUIT :remote custom quit") + quit_msg = await op.wait_for_user_msg("QUIT", timeout=5.0) + assert sender_nick(quit_msg) == "pe_rem6" + assert quit_msg.params[-1] == "Signed off", quit_msg.raw + + +@pytest.mark.single_server +async def test_M_notice_from_unregistered_is_dropped_silently(make_client): + """NOTICE has no error reply; the notice simply is not delivered.""" + op = await make_client("me_op1") + plain = await make_client("me_plain1") + chan = "#modem_notice" + await join(op, chan) + await op.send(f"MODE {chan} +M") + await op.wait_for("MODE") + await join(plain, chan) + await drain(op) + await drain(plain) + await plain.send(f"NOTICE {chan} :psst") + await op.assert_no_message("NOTICE", timeout=1.5) + await plain.assert_no_message("404", timeout=0.5) + + +@pytest.mark.single_server +async def test_M_opped_unregistered_member_can_speak(make_client): + op = await make_client("me_op2") + plain = await make_client("me_plain2") + chan = "#modem_opped" + await join(op, chan) + await op.send(f"MODE {chan} +M") + await op.wait_for("MODE") + await join(plain, chan) + await op.send(f"MODE {chan} +o me_plain2") + await op.wait_for("MODE") + await plain.wait_for("MODE") + await plain.send(f"PRIVMSG {chan} :opped hello") + msg = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert msg.params[-1] == "opped hello" + + +@pytest.mark.single_server +async def test_M_devoice_moderates_again(make_client): + op = await make_client("me_op3") + plain = await make_client("me_plain3") + chan = "#modem_devoice" + await join(op, chan) + await op.send(f"MODE {chan} +M") + await op.wait_for("MODE") + await join(plain, chan) + await op.send(f"MODE {chan} +v me_plain3") + await op.wait_for("MODE") + await plain.wait_for("MODE") + await op.send(f"MODE {chan} -v me_plain3") + await op.wait_for("MODE") + await plain.wait_for("MODE") + await plain.send(f"PRIVMSG {chan} :still allowed?") + err = await plain.wait_for("404", timeout=5.0) + assert err.params[1].lower() == chan + + +@pytest.mark.single_server +async def test_M_and_m_together(make_client): + """+Mm: voice lifts both restrictions for an account-less member.""" + op = await make_client("me_op4") + plain = await make_client("me_plain4") + chan = "#modem_both" + await join(op, chan) + await op.send(f"MODE {chan} +Mm") + await op.wait_for("MODE") + await join(plain, chan) + await plain.send(f"PRIVMSG {chan} :blocked") + await plain.wait_for("404", timeout=5.0) + await op.send(f"MODE {chan} +v me_plain4") + await plain.wait_for("MODE") + await plain.send(f"PRIVMSG {chan} :voiced") + msg = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert msg.params[-1] == "voiced" + + +@pytest.mark.multi_server +async def test_M_enforced_on_remote_member_server(ircd_network, make_client): + """A leaf user in a hub-created +M channel is moderated by its own server.""" + leaf = ircd_network["leaf1"] + op = await make_client("me_op5") + chan = "#modem_s2s" + await join(op, chan) + await op.send(f"MODE {chan} +M") + await op.wait_for("MODE") + remote = await make_client("me_rem5", host=leaf["host"], port=leaf["port"]) + await join(remote, chan) + await wait_for_join(op, chan, "me_rem5") + await asyncio.sleep(0.3) + await remote.send(f"PRIVMSG {chan} :from leaf") + err = await remote.wait_for("404", timeout=5.0) + assert err.params[1].lower() == chan + await op.assert_no_message("PRIVMSG", timeout=1.0) + + +@pytest.mark.single_server +async def test_M_persists_in_mode_query_with_other_modes(make_client): + op = await make_client("me_op6") + chan = "#modem_list" + await join(op, chan) + await op.send(f"MODE {chan} +Munt") + await op.wait_for("MODE") + modes = await chan_modes(op, chan) + for m in "Munt": + assert m in modes, modes + + +@pytest.mark.single_server +async def test_u_quit_hides_message_on_other_channels_too(make_client): + """The QUIT rewrite applies to every channel the user shares, not only +u ones.""" + op = await make_client("ue_op7") + other = await make_client("ue_other7") + quitter = await make_client("ue_quit7") + uchan, plain = "#modeu_multi_u", "#modeu_multi_plain" + await join(op, uchan) + await op.send(f"MODE {uchan} +u") + await op.wait_for("MODE") + await join(other, plain) + await join(quitter, uchan) + await join(quitter, plain) + await wait_for_join(other, plain, "ue_quit7") + await drain(other) + await quitter.send("QUIT :leaked?") + quit_msg = await other.wait_for_user_msg("QUIT", timeout=5.0) + assert quit_msg.params[-1] == "Signed off", quit_msg.raw + + +@pytest.mark.single_server +async def test_u_toggle_restores_part_messages(make_client): + op = await make_client("ue_op8") + leaver = await make_client("ue_leave8") + chan = "#modeu_toggle" + await join(op, chan) + await op.send(f"MODE {chan} +u") + await op.wait_for("MODE") + await op.send(f"MODE {chan} -u") + await op.wait_for("MODE") + await join(leaver, chan) + await wait_for_join(op, chan, "ue_leave8") + await drain(op) + await leaver.send(f"PART {chan} :bye again") + part = await op.wait_for_user_msg("PART", timeout=5.0) + assert part.params[-1] == "bye again", part.raw + + +@pytest.mark.single_server +async def test_clearmode_clears_u(make_client, oper): + """CLEARMODE removes +u like any other simple mode (do_clearmode table).""" + op = await make_client("ue_op9") + chan = "#modeu_clear" + await join(op, chan) + await op.send(f"MODE {chan} +un") + await op.wait_for("MODE") + assert "u" in await chan_modes(op, chan) + await oper.send(f"CLEARMODE {chan} u") + await op.wait_for("MODE", timeout=5.0) + modes = await chan_modes(op, chan) + assert "u" not in modes and "n" in modes, modes diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/test_command_edge_cases.py b/tests/commands/test_command_edge_cases.py new file mode 100644 index 00000000..c2d2bfcc --- /dev/null +++ b/tests/commands/test_command_edge_cases.py @@ -0,0 +1,156 @@ +"""Edge cases for WHOWAS, WHOX fields, CONNECT, PRIVS and remote STATS.""" + +from __future__ import annotations + +import pytest + +from common import collect +from irc_client import IRCClient + + +async def _register_and_quit(hub, nick, username): + client = IRCClient() + await client.connect(hub["host"], hub["port"]) + await client.register(nick, username, "WhoWas Test") + await client.send("QUIT :bye") + await client.disconnect() + + +async def _whowas(client, args): + await client.send(f"WHOWAS {args}") + msgs = await client.collect_until("369", timeout=8.0) + return msgs + + +@pytest.mark.single_server +async def test_whowas_comma_list_applies_limit_per_nick(ircd_hub, make_client): + for u in ("a1", "a2"): + await _register_and_quit(ircd_hub, "wwe_a", u) + for u in ("b1", "b2", "b3"): + await _register_and_quit(ircd_hub, "wwe_b", u) + client = await make_client("wwe_q1") + msgs = await _whowas(client, "wwe_a,wwe_b 0") + entries = [m.params[1].lower() for m in msgs if m.command == "314"] + assert entries.count("wwe_a") == 2 and entries.count("wwe_b") == 3, entries + msgs = await _whowas(client, "wwe_a,wwe_b 1") + entries = [m.params[1].lower() for m in msgs if m.command == "314"] + assert entries.count("wwe_a") == 1 and entries.count("wwe_b") == 1, entries + assert msgs[-1].params[1].lower() == "wwe_a,wwe_b" + + +@pytest.mark.multi_server +async def test_whowas_remote_requires_oper(ircd_network, make_client): + client = await make_client("wwe_q2") + await client.send("WHOWAS somebody 0 leaf1.test.net") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" + + +@pytest.mark.multi_server +async def test_whowas_remote_zero_is_unlimited_up_to_cap(ircd_network, oper): + leaf = ircd_network["leaf1"] + for u in ("r1", "r2", "r3"): + await _register_and_quit(leaf, "wwe_rem", u) + msgs = await _whowas(oper, "wwe_rem 0 leaf1.test.net") + entries = [m for m in msgs if m.command == "314"] + assert len(entries) == 3, [m.raw for m in msgs] + + +@pytest.mark.single_server +async def test_whox_all_fields_keep_idle_before_account(make_client): + """Field order t,c,u,i,h,s,n,f,d,l,a,r: idle sits right before account.""" + # (the querytype 42 is only echoed when 't' is requested) + client = await make_client("whoxe1") + await client.send("WHO whoxe1 %tnla,42") + msgs = await client.collect_until("315", timeout=5.0) + rows = [m for m in msgs if m.command == "354"] + assert len(rows) == 1 + # 354 42 + assert rows[0].params[1] == "42", rows[0].raw + assert rows[0].params[2] == "whoxe1", rows[0].raw + assert rows[0].params[3].isdigit(), rows[0].raw + assert rows[0].params[4] == "0", rows[0].raw + + +@pytest.mark.single_server +async def test_whox_idle_of_other_user_hidden_from_non_oper(make_client): + other = await make_client("whoxe2o") + client = await make_client("whoxe2") + await client.send("WHO whoxe2o %nl") + msgs = await client.collect_until("315", timeout=5.0) + rows = [m for m in msgs if m.command == "354"] + assert len(rows) == 1 and rows[0].params[1] == "whoxe2o" + assert rows[0].params[2] == "0", rows[0].raw + + +@pytest.mark.multi_server +async def test_remote_connect_with_port_zero(ircd_network, make_client): + """CONNECT 0 is forwarded and uses the conf port there.""" + from cap_helpers import oper_up + + leaf = ircd_network["leaf1"] + leaf_oper = await make_client("cone_op1", host=leaf["host"], port=leaf["port"]) + await oper_up(leaf_oper) + await leaf_oper.send("CONNECT notulined.test.net 0 hub.test.net") + notices = [m.params[-1] for m in await collect(leaf_oper, 3.0) + if m.command == "NOTICE" and m.params[-1].startswith(("Connect:", "***"))] + assert notices, "expected a Connect notice from the hub" + assert not any("Invalid port" in n or "missing port" in n for n in notices), notices + + +@pytest.mark.single_server +async def test_connect_unknown_via_server(oper): + await oper.send("CONNECT notulined.test.net 0 no.such.server") + err = await oper.wait_for("402", timeout=5.0) + assert err.params[1] == "no.such.server" + + +@pytest.mark.single_server +async def test_connect_needs_target(oper): + await oper.send("CONNECT") + err = await oper.wait_for("461", timeout=5.0) + assert err.params[1] == "CONNECT" + + +@pytest.mark.single_server +async def test_privs_of_non_oper_is_empty(oper, make_client): + plain = await make_client("prive_plain") + await oper.send("PRIVS prive_plain") + msg = await oper.wait_for("270", timeout=5.0) + assert msg.params[-1].strip() == "", msg.raw + + +@pytest.mark.multi_server +async def test_privs_remote_oper(ircd_network, oper, make_client): + from cap_helpers import oper_up + + leaf = ircd_network["leaf1"] + leaf_oper = await make_client("prive_lop", host=leaf["host"], port=leaf["port"]) + await oper_up(leaf_oper) + await oper.send("PRIVS prive_lop") + msg = await oper.wait_for("270", timeout=5.0) + privs = {p.lower() for p in msg.params[-1].split()} + assert "unlimit_query" in privs, privs + + +@pytest.mark.multi_server +async def test_remote_stats_requires_privileges(ircd_network, make_client): + client = await make_client("state_plain") + await client.send("STATS u leaf1.test.net") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" + + +@pytest.mark.multi_server +async def test_remote_stats_unknown_server(ircd_network, oper): + await oper.send("STATS P no.such.server 6668") + err = await oper.wait_for("402", timeout=5.0) + assert err.params[1] == "no.such.server" + + +@pytest.mark.multi_server +async def test_remote_stats_p_port_filter_no_match(ircd_network, oper): + await oper.send("STATS P leaf1.test.net 1234") + msgs = await oper.collect_until("219", timeout=8.0) + assert not [m for m in msgs if m.command == "217"] + assert msgs[-1].params[1] == "P" diff --git a/tests/commands/test_connect.py b/tests/commands/test_connect.py new file mode 100644 index 00000000..4a8ae983 --- /dev/null +++ b/tests/commands/test_connect.py @@ -0,0 +1,57 @@ +"""/CONNECT 0 uses the port from the Connect block (commit 45ae2f0). + +"notulined.test.net" is configured with port 4499 where nothing listens, so +every attempt fails quickly; what matters is which notice the oper gets. +""" + +from __future__ import annotations + +import pytest + +from common import collect + +pytestmark = pytest.mark.single_server + + +async def _connect_notices(oper, args): + await oper.send(f"CONNECT {args}") + return [ + m.params[-1] + for m in await collect(oper, 2.5) + if m.command == "NOTICE" and m.params[-1].startswith(("Connect:", "***")) + ] + + +def _attempted(notices): + return any("Connecting to notulined.test.net" in n or + "Connection to notulined.test.net failed" in n for n in notices) + + +async def test_port_zero_uses_configured_port(oper): + notices = await _connect_notices(oper, "notulined.test.net 0") + assert notices, "expected a Connect notice" + assert not any("Invalid port" in n or "missing port" in n for n in notices), notices + assert _attempted(notices), notices + + +async def test_non_numeric_port_is_rejected(oper): + notices = await _connect_notices(oper, "notulined.test.net abc") + assert any("Invalid port number" in n for n in notices), notices + assert not _attempted(notices), notices + + +async def test_explicit_port_is_used(oper): + notices = await _connect_notices(oper, "notulined.test.net 4499") + assert _attempted(notices), notices + + +async def test_unknown_server_is_reported(oper): + notices = await _connect_notices(oper, "nowhere.test.net 0") + assert any("not listed in ircd.conf" in n for n in notices), notices + + +async def test_connect_requires_oper(make_client): + client = await make_client("connplain") + await client.send("CONNECT notulined.test.net 0") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py new file mode 100644 index 00000000..1964893b --- /dev/null +++ b/tests/commands/test_info.py @@ -0,0 +1,84 @@ +"""INFO hides the source-file hash section from non-operators (commit 9cbac8b, +issue #29; reworked on main to stop at the "Sources:" marker instead of a +hard-coded line offset). + +Non-opers get the fixed text up to (excluding) "Sources:"; an operator who +names a server (``INFO hub.test.net``) additionally gets the hash section. +""" + +from __future__ import annotations + +import re + +import pytest + +pytestmark = pytest.mark.single_server + +HASH_LINE = re.compile(r"^\[ .+: [0-9a-f]{32} .*\]$") +FOOTER = ("Birth Date:", "On-line since", "TLS library:") + + +async def _info(client, arg=""): + await client.send(f"INFO {arg}".strip()) + msgs = await client.collect_until("374", timeout=10.0) + return [m.params[-1] for m in msgs if m.command == "371"] + + +def _hashes(lines): + return [l for l in lines if HASH_LINE.match(l)] + + +async def test_non_oper_does_not_see_hashes(make_client): + client = await make_client("info1") + lines = await _info(client) + assert lines and lines[0] == "IRC --", lines[:3] + assert "Sources:" not in lines and "Headers:" not in lines + assert not _hashes(lines), _hashes(lines)[:3] + assert any(l.startswith("Birth Date:") for l in lines) + assert any(l.startswith("On-line since") for l in lines) + + +async def test_non_oper_remote_info_needs_privileges(make_client): + """INFO is oper-only for the remote form (hunt_server_cmd MustBeOper).""" + client = await make_client("info2") + await client.send("INFO leaf1.test.net") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" + + +async def test_oper_sees_hashes(oper): + """An oper naming a server gets the hash section (sources and headers).""" + lines = await _info(oper, "hub.test.net") + hashes = _hashes(lines) + assert "Headers:" in lines + assert len(hashes) > 100, f"oper saw only {len(hashes)} hash lines" + assert any("client.h" in l for l in hashes) + + +@pytest.mark.xfail( + reason="mo_info() still skips text[218] entries (m_info/ms_info were fixed to stop at " + "'Sources:'), so opers lose the first source hashes as the file count grows", + strict=True, +) +async def test_oper_sees_every_source_hash(oper): + lines = await _info(oper, "hub.test.net") + hashes = _hashes(lines) + assert "Sources:" in lines, lines[:3] + assert any(l.startswith("[ IPcheck.c:") for l in hashes), hashes[:2] + assert any(l.startswith("[ channel.c:") for l in hashes), hashes[:2] + + +async def test_oper_without_server_argument_sees_no_hashes(oper): + """mo_info only sends the hash section when a server name is given.""" + lines = await _info(oper) + assert not _hashes(lines), _hashes(lines)[:3] + assert any(l.startswith("Birth Date:") for l in lines) + + +async def test_oper_hash_lines_are_well_formed(oper): + """Each hash line names a source file and a 32-hex MD5 (umkpasswd -5).""" + lines = await _info(oper, "hub.test.net") + hashes = _hashes(lines) + names = [l.split(":")[0].lstrip("[ ") for l in hashes] + assert len(set(names)) == len(names), "duplicate file names in INFO" + assert all(n.endswith((".c", ".y", ".h", ".SH")) for n in names), names[:5] diff --git a/tests/commands/test_part.py b/tests/commands/test_part.py new file mode 100644 index 00000000..5b615fef --- /dev/null +++ b/tests/commands/test_part.py @@ -0,0 +1,56 @@ +"""PART of a channel one is not on is answered with ERR_NOTONCHANNEL and +does not disturb the connection (commit 7e978f9 made joinbuf_join() bail +early when there is no membership).""" + +from __future__ import annotations + +import pytest + +from common import join + +pytestmark = pytest.mark.single_server + + +async def test_part_not_member(make_client): + other = await make_client("partown1") + await join(other, "#part_notmember") + client = await make_client("partnm1") + await client.send("PART #part_notmember :not here") + err = await client.wait_for("442", timeout=5.0) + assert err.params[1].lower() == "#part_notmember" + await client.send("PING :alive") + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "alive" + + +async def test_part_list_mixed_membership(make_client): + keeper = await make_client("partkeep2") + await join(keeper, "#part_mix_b") + client = await make_client("partmix2") + await join(client, "#part_mix_a") + await client.send("PART #part_mix_a,#part_mix_b,#part_mix_none :leaving") + part = await client.wait_for("PART", timeout=5.0) + assert part.params[0].lower() == "#part_mix_a" + err = await client.wait_for("442", timeout=5.0) + assert err.params[1].lower() == "#part_mix_b" + err = await client.wait_for("403", timeout=5.0) + assert err.params[1].lower() == "#part_mix_none" + + +async def test_part_twice(make_client): + keeper = await make_client("partkeep3") + await join(keeper, "#part_twice") + client = await make_client("parttwice3") + await join(client, "#part_twice") + await client.send("PART #part_twice") + await client.wait_for("PART", timeout=5.0) + await client.send("PART #part_twice") + err = await client.wait_for("442", timeout=5.0) + assert err.params[1].lower() == "#part_twice" + + +async def test_part_unknown_channel(make_client): + client = await make_client("partnone4") + await client.send("PART #part_never_existed") + err = await client.wait_for("403", timeout=5.0) + assert err.params[1].lower() == "#part_never_existed" diff --git a/tests/commands/test_privs.py b/tests/commands/test_privs.py new file mode 100644 index 00000000..34138d27 --- /dev/null +++ b/tests/commands/test_privs.py @@ -0,0 +1,34 @@ +"""Default global operator privileges include unlimit_query again (commit +213e982); the other historical exclusions are unchanged.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.single_server + + +async def _privs(client, nick): + await client.send(f"PRIVS {nick}") + msg = await client.wait_for("270", timeout=5.0) + return {p.lower() for p in msg.params[-1].split()} + + +async def test_default_global_oper_privs(oper): + privs = await _privs(oper, oper.nick) + assert "unlimit_query" in privs, privs + # Granted explicitly in the Operator block. + assert "set" in privs and "wide_gline" in privs, privs + # Still excluded by default for global opers. + for excluded in ("walk_lchan", "badchan", "local_badchan", "apass_opmode"): + assert excluded not in privs, privs + # A couple of the always-granted privileges. + for granted in ("chan_limit", "show_invis", "kill", "gline", "rehash"): + assert granted in privs, privs + + +async def test_privs_is_oper_only(make_client): + client = await make_client("privsplain") + await client.send("PRIVS privsplain") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" diff --git a/tests/commands/test_stats_remote.py b/tests/commands/test_stats_remote.py new file mode 100644 index 00000000..e9620841 --- /dev/null +++ b/tests/commands/test_stats_remote.py @@ -0,0 +1,44 @@ +"""STATS forwards its optional extra parameter to the target server and only +then decides whether the local handler accepts it (commit 1f5142d).""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.multi_server + + +async def _stats(client, args, end="219"): + await client.send(f"STATS {args}") + return await client.collect_until(end, timeout=8.0) + + +def _ports(msgs): + return sorted(m.params[2] for m in msgs if m.command == "217") + + +async def test_remote_stats_p_with_port_filter(ircd_network, oper): + msgs = await _stats(oper, "P leaf1.test.net 6668") + assert _ports(msgs) == ["6668"], _ports(msgs) + assert msgs[-1].prefix == "leaf1.test.net", msgs[-1].raw + + +async def test_remote_stats_p_without_filter_lists_all(ircd_network, oper): + msgs = await _stats(oper, "P leaf1.test.net") + assert _ports(msgs) == ["4401", "6668", "6690"], _ports(msgs) + + +async def test_local_stats_p_with_port_filter(ircd_network, oper): + msgs = await _stats(oper, "P hub.test.net 6667") + assert _ports(msgs) == ["6667"], _ports(msgs) + + +async def test_extra_param_ignored_for_non_varparam_stats(ircd_network, oper): + """A stats type without VARPARAM still works with a stray parameter.""" + local = await _stats(oper, "m hub.test.net junkparam") + assert any(m.command == "212" for m in local) + assert local[-1].command == "219" and local[-1].prefix == "hub.test.net" + + remote = await _stats(oper, "m leaf1.test.net junkparam") + assert any(m.command == "212" for m in remote) + assert remote[-1].prefix == "leaf1.test.net" diff --git a/tests/commands/test_who_fields.py b/tests/commands/test_who_fields.py new file mode 100644 index 00000000..203d2315 --- /dev/null +++ b/tests/commands/test_who_fields.py @@ -0,0 +1,38 @@ +"""WHOX %l selects only the idle field (commit 17c5391 added a missing break +so 'l' no longer fell through into 'n').""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.single_server + + +async def _whox(client, mask, fields): + await client.send(f"WHO {mask} %{fields}") + msgs = await client.collect_until("315", timeout=5.0) + return [m for m in msgs if m.command == "354"] + + +async def test_idle_field_alone(make_client): + client = await make_client("whox1") + rows = await _whox(client, "whox1", "l") + assert len(rows) == 1 + # 354 : nothing else, in particular no nick field. + assert len(rows[0].params) == 2, rows[0].raw + assert rows[0].params[1].isdigit(), rows[0].raw + + +async def test_nick_field_alone(make_client): + client = await make_client("whox2") + rows = await _whox(client, "whox2", "n") + assert len(rows) == 1 + assert rows[0].params[1:] == ["whox2"], rows[0].raw + + +async def test_idle_and_nick_fields(make_client): + client = await make_client("whox3") + rows = await _whox(client, "whox3", "nl") + assert len(rows) == 1 + assert rows[0].params[1] == "whox3", rows[0].raw + assert rows[0].params[2].isdigit(), rows[0].raw diff --git a/tests/commands/test_whowas.py b/tests/commands/test_whowas.py new file mode 100644 index 00000000..ee07f8f4 --- /dev/null +++ b/tests/commands/test_whowas.py @@ -0,0 +1,56 @@ +"""WHOWAS 0 means "no limit" (commit c121933).""" + +from __future__ import annotations + +import pytest + +from irc_client import IRCClient + +pytestmark = pytest.mark.single_server + + +async def _register_and_quit(hub, nick, username): + client = IRCClient() + await client.connect(hub["host"], hub["port"]) + await client.register(nick, username, "WhoWas Test") + await client.send("QUIT :bye") + await client.disconnect() + + +async def _whowas(client, nick, arg=None): + await client.send(f"WHOWAS {nick}" + (f" {arg}" if arg is not None else "")) + msgs = await client.collect_until("369", timeout=5.0) + return [m for m in msgs if m.command == "314"] + + +async def test_whowas_zero_returns_all_entries(ircd_hub, make_client): + for username in ("wwone", "wwtwo", "wwthree"): + await _register_and_quit(ircd_hub, "wwzero1", username) + client = await make_client("wwq1") + + all_entries = await _whowas(client, "wwzero1") + assert len(all_entries) == 3 + assert [m.params[2].lstrip("~") for m in all_entries] == ["wwthree", "wwtwo", "wwone"] + + limited = await _whowas(client, "wwzero1", 1) + assert len(limited) == 1 and limited[0].params[2].lstrip("~") == "wwthree" + + two = await _whowas(client, "wwzero1", 2) + assert len(two) == 2 + + unlimited = await _whowas(client, "wwzero1", 0) + assert len(unlimited) == 3, "WHOWAS 0 must not stop after the first entry" + + +async def test_whowas_negative_count_is_unlimited(ircd_hub, make_client): + for username in ("nega", "negb"): + await _register_and_quit(ircd_hub, "wwneg2", username) + client = await make_client("wwq2") + assert len(await _whowas(client, "wwneg2", -1)) == 2 + + +async def test_whowas_unknown_nick(make_client): + client = await make_client("wwq3") + await client.send("WHOWAS nobody_ever_here 0") + msgs = await client.collect_until("369", timeout=5.0) + assert any(m.command == "406" for m in msgs) diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000..2aa63883 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,108 @@ +"""Small shared helpers for the integration tests.""" + +from __future__ import annotations + +import asyncio + +from irc_client import IRCClient, Message + + +async def join(client: IRCClient, channel: str, timeout: float = 5.0) -> Message: + """JOIN a channel and wait for the server to echo our own JOIN.""" + await client.send(f"JOIN {channel}") + return await wait_for_join(client, channel, client.nick, timeout=timeout) + + +async def wait_for_join( + client: IRCClient, channel: str, nick: str, timeout: float = 5.0 +) -> Message: + """Wait for a JOIN to ``channel`` from ``nick`` (case-insensitive).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise asyncio.TimeoutError(f"no JOIN {channel} from {nick}") + msg = await client.wait_for("JOIN", timeout=remaining) + if ( + msg.prefix + and msg.prefix.split("!", 1)[0].lower() == nick.lower() + and msg.params + and msg.params[0].lower() == channel.lower() + ): + return msg + + +async def drain(client: IRCClient, seconds: float = 0.4) -> list[Message]: + """Consume (and return) whatever arrives within ``seconds``.""" + out: list[Message] = [] + deadline = asyncio.get_running_loop().time() + seconds + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return out + try: + out.append(await client.recv(timeout=remaining)) + except (asyncio.TimeoutError, ConnectionError): + return out + + +async def collect(client: IRCClient, seconds: float, command: str | None = None) -> list[Message]: + """Collect messages for ``seconds``, optionally filtered by command.""" + msgs = await drain(client, seconds) + if command is None: + return msgs + return [m for m in msgs if m.command.upper() == command.upper()] + + +def sender_nick(msg: Message) -> str: + """Nick part of a user prefix ('' for server-prefixed messages).""" + if not msg.prefix or "!" not in msg.prefix: + return "" + return msg.prefix.split("!", 1)[0] + + +async def whois(client: IRCClient, nick: str, timeout: float = 5.0) -> dict[str, Message]: + """Send WHOIS and return the numeric replies keyed by numeric.""" + await client.send(f"WHOIS {nick}") + msgs = await client.collect_until("318", timeout=timeout) + return {m.command: m for m in msgs} + + +async def chan_modes(client: IRCClient, channel: str) -> str: + """Channel mode letters from RPL_CHANNELMODEIS (without the leading '+').""" + modes = await client.chan_modes(channel) + return modes.lstrip("+") + + +async def get_feature(oper: IRCClient, name: str, timeout: float = 10.0) -> str: + """Return the value text of a feature ("TRUE", "FALSE", an int or string). + + Any pending RPL_FEATURE replies (e.g. from an earlier SET) are discarded + first so the answer really belongs to this GET. + """ + await drain(oper, 0.2) + await oper.send(f"GET {name}") + msg = await oper.wait_for("284", timeout=timeout) + # ":Boolean value of NAME: TRUE" / ":Integer value of NAME: 5" / + # ":String value of NAME: text" + return msg.params[-1].split(f"of {name}: ", 1)[1] + + +async def set_feature(oper: IRCClient, name: str, value: str, timeout: float = 10.0) -> None: + """SET a feature and wait until the server has applied it. + + SET only answers (with RPL_FEATURE) when the value actually changes, and + ircu may defer a client's commands for a couple of seconds once its flood + penalty builds up, so a fixed sleep after SET is racy. Compare first, + then wait for the reply that proves the change was processed. + """ + current = await get_feature(oper, name, timeout=timeout) + if current.upper() == value.upper() or ( + current in ("TRUE", "FALSE") and value in ("1", "0") + and (current == "TRUE") == (value == "1") + ): + return + await oper.send(f"SET {name} {value}") + msg = await oper.wait_for("284", timeout=timeout) + assert f"of {name}: " in msg.params[-1], msg.raw diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/config/test_include.py b/tests/config/test_include.py new file mode 100644 index 00000000..c91ef6fb --- /dev/null +++ b/tests/config/test_include.py @@ -0,0 +1,177 @@ +"""Configuration file inclusion and the hand-coded lexer (commits 66304e0, +844238f, e50a5c3). + +Each case writes a small configuration into the hub container and runs +``ircd -k`` (check configuration and exit). ``-c user@ip`` prints the +Client block that would be attached, which proves blocks from an included +file were loaded. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from conftest import docker_cp_text, docker_exec + +pytestmark = pytest.mark.single_server + +TMP = "/opt/ircu/tmp" + +BASE = """\ +General { name = "check.test.net"; description = "check"; numeric = 9; }; +Admin { Location = "Test"; Contact = "test@test.net"; }; +Class { name = "Local"; pingfreq = 90 seconds; sendq = 160000; maxlinks = 10; }; +%(extra)s +Port { port = 6999; }; +""" + + +def _check(container, name, files, client=None): + # ircd refuses to run as root; the container's exec default is root. + docker_exec(container, "sh", "-c", f"mkdir -p {TMP} && chmod 777 {TMP}") + for fname, text in files.items(): + docker_cp_text(container, f"{TMP}/{fname}", text) + cmd = ["timeout", "-s", "KILL", "15", "/opt/ircu/bin/ircd", "-k", "-d", TMP, "-f", f"{TMP}/{name}"] + if client: + cmd += ["-c", client] + return docker_exec(container, *cmd, timeout=30, user="ircu") + + +def _ok(result: subprocess.CompletedProcess): + return result.returncode == 0 and "checked okay" in result.stderr + + +async def test_plain_config_checks_okay(ircd_hub): + files = {"plain.conf": BASE % {"extra": 'Client { ip = "*"; class = "Local"; };'}} + result = _check(ircd_hub["container"], "plain.conf", files) + assert _ok(result), result + + +async def test_included_file_is_loaded(ircd_hub): + files = { + "inc_main.conf": BASE % {"extra": 'Include "inc_extra.conf";'}, + "inc_extra.conf": '# included\nClient { ip = "*"; class = "Local"; };\n', + } + result = _check(ircd_hub["container"], "inc_main.conf", files, client="probe@10.55.0.1") + assert _ok(result), result + assert "Match!" in result.stdout + result.stderr, result + assert "class=Local" in result.stdout + result.stderr, result + + +async def test_nested_include(ircd_hub): + files = { + "nest_main.conf": BASE % {"extra": 'Include "nest_mid.conf";'}, + "nest_mid.conf": 'Class { name = "Nested"; pingfreq = 90 seconds; sendq = 1000; maxlinks = 5; };\n' + 'include "nest_leaf.conf";\n', + "nest_leaf.conf": 'Client { ip = "*"; class = "Nested"; };\n', + } + result = _check(ircd_hub["container"], "nest_main.conf", files, client="probe@10.55.0.1") + assert _ok(result), result + assert "class=Nested" in result.stdout + result.stderr, result + + +async def test_include_keyword_is_case_insensitive(ircd_hub): + files = { + "case_main.conf": BASE % {"extra": 'INCLUDE "case_extra.conf";'}, + "case_extra.conf": 'Client { ip = "*"; class = "Local"; };\n', + } + result = _check(ircd_hub["container"], "case_main.conf", files, client="probe@10.55.0.1") + assert _ok(result), result + assert "Match!" in result.stdout + result.stderr, result + + +async def test_syntax_error_in_included_file_is_reported(ircd_hub): + files = { + "bad_main.conf": BASE % {"extra": 'Include "bad_extra.conf";'}, + "bad_extra.conf": 'Client { ip = "*" class = "Local"; };\n', # missing ';' + } + result = _check(ircd_hub["container"], "bad_main.conf", files) + assert result.returncode not in (0, 137), result + assert "bad_extra.conf" in result.stderr, result.stderr + + +async def test_missing_include_file_is_reported(ircd_hub): + files = {"miss_main.conf": BASE % {"extra": 'Include "does_not_exist.conf";'}} + result = _check(ircd_hub["container"], "miss_main.conf", files) + assert result.returncode != 0, result + assert "error opening file" in result.stderr, result.stderr + + +@pytest.mark.xfail( + reason="ircd never exits after failing to open an Include file (killed by timeout)", + strict=True, +) +async def test_missing_include_file_exits_promptly(ircd_hub): + files = {"miss2_main.conf": BASE % {"extra": 'Include "does_not_exist.conf";'}} + result = _check(ircd_hub["container"], "miss2_main.conf", files) + assert result.returncode != 137, "ircd hung and was killed by timeout" + + +@pytest.mark.xfail( + reason="the hand-coded lexer has no FROM token, so 'Include from \"file\"' is a syntax error", + strict=True, +) +async def test_include_restricted_to_block_types(ircd_hub): + files = { + "types_main.conf": BASE % {"extra": 'Include Client from "types_extra.conf";'}, + "types_extra.conf": 'Client { ip = "*"; class = "Local"; };\n', + } + result = _check(ircd_hub["container"], "types_main.conf", files, client="probe@10.55.0.1") + assert _ok(result), result + + +async def test_hash_comments_and_quoted_strings(ircd_hub): + files = { + "lex.conf": BASE % {"extra": ( + '# a comment line\n' + 'Client { ip = "*"; class = "Local"; }; # trailing comment\n' + ' # indented comment with "quotes" and ; braces {}\n' + 'Features { "MOTD_BANNER" = "hash # inside quotes is not a comment"; };\n' + )}, + } + result = _check(ircd_hub["container"], "lex.conf", files) + assert _ok(result), result + + +@pytest.mark.xfail( + reason="the grammar requires at least one block per included file, so an empty/comment-only include is a syntax error", + strict=True, +) +async def test_include_of_empty_and_comment_only_file(ircd_hub): + files = { + "empty_main.conf": BASE % {"extra": 'Client { ip = "*"; class = "Local"; };\nInclude "empty_extra.conf";'}, + "empty_extra.conf": "# nothing here\n\n", + } + result = _check(ircd_hub["container"], "empty_main.conf", files) + assert _ok(result), result + + +@pytest.mark.xfail( + reason="a self-including file aborts ircd (SIGABRT after 'memory exhausted' from the parser)", + strict=True, +) +async def test_include_cycle_is_not_fatal(ircd_hub): + """A file that includes itself must not make ircd loop or crash.""" + files = { + "cycle_main.conf": BASE % {"extra": 'Client { ip = "*"; class = "Local"; };\nInclude "cycle_self.conf";'}, + "cycle_self.conf": 'Include "cycle_self.conf";\n', + } + result = _check(ircd_hub["container"], "cycle_main.conf", files) + assert result.returncode != 137, "ircd hung on a self-including file" + assert result.returncode != 134, f"ircd aborted on a self-including file: {result.stderr[-200:]}" + assert result.returncode != 0 + + +async def test_include_relative_to_dpath(ircd_hub): + """Include paths are resolved relative to the working directory (-d).""" + files = { + "rel_main.conf": BASE % {"extra": 'Include "sub/rel_extra.conf";'}, + } + docker_exec(ircd_hub["container"], "sh", "-c", f"mkdir -p {TMP}/sub && chmod 777 {TMP} {TMP}/sub") + docker_cp_text(ircd_hub["container"], f"{TMP}/sub/rel_extra.conf", + 'Client { ip = "*"; class = "Local"; };\n') + result = _check(ircd_hub["container"], "rel_main.conf", files, client="probe@10.55.0.1") + assert _ok(result), result + assert "Match!" in result.stdout + result.stderr diff --git a/tests/conftest.py b/tests/conftest.py index 7afdf410..72ccb018 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,9 +20,29 @@ # docker-compose.yml and Dockerfile live in the repo root (parent of tests/) REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -HUB = {"host": "127.0.0.1", "port": 6667, "server_port": 4400, "name": "hub.test.net"} -LEAF1 = {"host": "127.0.0.1", "port": 6668, "server_port": 4401, "name": "leaf1.test.net", "exempt_port": 6690} -LEAF2 = {"host": "127.0.0.1", "port": 6669, "server_port": 4402, "name": "leaf2.test.net"} +HUB = { + "host": "127.0.0.1", + "port": 6667, + "server_port": 4400, + "name": "hub.test.net", + "container": "ircu-hub", +} +LEAF1 = { + "host": "127.0.0.1", + "port": 6668, + "server_port": 4401, + "name": "leaf1.test.net", + "exempt_port": 6690, + "container": "ircu-leaf1", +} +LEAF2 = { + "host": "127.0.0.1", + "port": 6669, + "server_port": 4402, + "webirc_port": 6691, + "name": "leaf2.test.net", + "container": "ircu-leaf2", +} TLS_HUB = { "host": "127.0.0.1", @@ -95,6 +115,30 @@ def docker_compose(*args, check=True): return result +def docker_exec(container: str, *cmd: str, timeout: float = 60.0, user: str | None = None): + """Run a command inside a running test container (as root unless ``user``).""" + args = ["docker", "exec"] + if user: + args += ["-u", user] + return subprocess.run( + args + [container] + list(cmd), + capture_output=True, + text=True, + timeout=timeout, + ) + + +def docker_cp_text(container: str, path: str, text: str): + """Write ``text`` to ``path`` inside a running container.""" + subprocess.run( + ["docker", "exec", "-i", container, "sh", "-c", f"cat > {path}"], + input=text, + text=True, + check=True, + timeout=60, + ) + + LIMITS = { "host": "127.0.0.1", "port": 6670, @@ -594,9 +638,14 @@ async def _make( realname: str = "Test User", host: str | None = None, port: int | None = None, + caps: list[str] | None = None, ) -> IRCClient: client = IRCClient() await client.connect(host or ircd_hub["host"], port or ircd_hub["port"]) + if caps: + acked = await client.negotiate_cap(caps) + missing = [c for c in caps if c not in acked] + assert not missing, f"CAP(s) not acknowledged: {missing} (acked={acked})" await client.register(nick, username, realname) clients.append(client) return client @@ -611,6 +660,16 @@ async def _make( await client.disconnect() +@pytest_asyncio.fixture +async def oper(make_client): + """A registered global operator on the hub.""" + from cap_helpers import oper_up + + client = await make_client("testop") + await oper_up(client) + return client + + @pytest_asyncio.fixture async def ulined_server(ircd_hub): """A U:lined P10 server linked to the hub. diff --git a/tests/docker/iauth-test.pl b/tests/docker/iauth-test.pl new file mode 100755 index 00000000..f8abc9bb --- /dev/null +++ b/tests/docker/iauth-test.pl @@ -0,0 +1,84 @@ +#! /usr/bin/perl +# IAuth stub for the integration tests (attached to leaf1). +# +# Policy ARUS: the server sends U/P (A), waits for our verdict (R), sends +# n/u/H (U), and uses the asynchronous "? stats2" statistics request (S). +# +# Every client is approved (D) as soon as its nickname arrives. The stub +# remembers, per nickname, the "U" (USER command) and "u" (confirmed +# username) values the server reported, and exposes them through the +# statistics reply so tests can check what ircd sent (/STATS iauth). +# +# The "? stats2" reply deliberately starts with a fragment terminated by a +# bare carriage return: ircd must log and skip it without desynchronising. +use strict; +use warnings; +use FileHandle; + +my %pending; # id => { id, ip, port, U, u, nick } +my %by_nick; # nick => summary string +my @order; # nick insertion order (bounded) +my $config_requests = 0; +my $stats_requests = 0; + +sub reply { + my ($msg, $client) = @_; + return unless defined $msg; + $msg =~ s/^(.) ?/$1 $client->{id} $client->{ip} $client->{port} / if $client; + print "$msg\n"; +} + +autoflush STDOUT 1; +print "V :iauth-test 1.0\n"; +print "O ARUS\n"; + +while (<>) { + s/\r?\n?\r?$//; + my $client; + if (s/^(-?\d+) //) { + my $id = $1; + $client = $pending{$id}; + if (/^C (\S+) (\S+)/) { + $pending{$id} = { id => $id, ip => $1, port => $2 }; + next; + } + if (/^\? config$/) { + $config_requests++; + print "a\n"; + print "A * iauth-test :policy=ARUS\n"; + print "A * iauth-test :config-requests=$config_requests\n"; + next; + } elsif (/^\? stats$/) { + $stats_requests++; + print "s\n"; + print "S iauth-test :stats-requests=$stats_requests\n"; + next; + } elsif (/^\? stats2$/) { + $stats_requests++; + # Fragment terminated by a bare CR: must be dropped by ircd. + print "S iauth-test :garbage-fragment\r"; + print "S iauth-test :stats-requests=$stats_requests\n"; + for my $nick (@order) { + print "S client :$by_nick{$nick}\n" if exists $by_nick{$nick}; + } + print "s\n"; + next; + } + next unless $client; + if (/^[DT]$/) { + delete $pending{$id}; + } elsif (/^U (\S*)/) { + $client->{U} = $1; + } elsif (/^u ?(\S*)/) { + $client->{u} = defined $1 ? $1 : ''; + } elsif (/^n (\S+)/) { + my $nick = $1; + $client->{nick} = $nick; + $by_nick{lc $nick} = sprintf("nick=%s U=%s u=%s", $nick, + $client->{U} // '-', $client->{u} // '-'); + push @order, lc $nick; + delete $by_nick{shift @order} while @order > 50; + reply("D", $client); + } + } +} diff --git a/tests/docker/ircd-hub.conf b/tests/docker/ircd-hub.conf index 9fdfd5a5..306c82ab 100644 --- a/tests/docker/ircd-hub.conf +++ b/tests/docker/ircd-hub.conf @@ -47,9 +47,11 @@ Connect { hub; }; +# Never linked; port 4499 has no listener (used by tests/commands/test_connect.py). Connect { name = "notulined.test.net"; host = "10.55.0.1"; + port = 4499; password = "testpass"; class = "Server"; }; @@ -119,9 +121,12 @@ Port { websocket = yes; port = 7002; }; Features { "HUB" = "TRUE"; "NODNS" = "TRUE"; +# The ident probe to the docker host may be dropped; do not wait the default 9s +# (the timeout only marks ident/DNS as failed, it never drops a client). + "AUTH_TIMEOUT" = "3"; "CONFIG_OPERCMDS" = "TRUE"; "CHANNELLEN" = "50"; - "MAXCHANNELSPERUSER" = "20"; + "MAXCHANNELSPERUSER" = "40"; "CAP_MESSAGE_TAGS" = "TRUE"; "CAP_SERVER_TIME" = "TRUE"; "CAP_ACCOUNT_TAG" = "TRUE"; diff --git a/tests/docker/ircd-leaf1.conf b/tests/docker/ircd-leaf1.conf index c69228eb..b6eff246 100644 --- a/tests/docker/ircd-leaf1.conf +++ b/tests/docker/ircd-leaf1.conf @@ -73,6 +73,7 @@ Port { port = 6690; }; Features { "NODNS" = "TRUE"; + "MAXCHANNELSPERUSER" = "40"; "CONFIG_OPERCMDS" = "TRUE"; "CAP_MESSAGE_TAGS" = "TRUE"; "CAP_SERVER_TIME" = "TRUE"; diff --git a/tests/docker/ircd-leaf2.conf b/tests/docker/ircd-leaf2.conf index 48844301..e5bada5a 100644 --- a/tests/docker/ircd-leaf2.conf +++ b/tests/docker/ircd-leaf2.conf @@ -40,9 +40,19 @@ Class { maxlinks = 100; }; -Client { ip = "*"; class = "Local"; }; +# The username mask turns on DoIdentLookups: ident is queried and a failed +# lookup prefixes ~ to the USER name (tests/username/). +Client { ip = "*"; class = "Local"; username = "*"; }; -IAuth { program = "/opt/ircu/bin/iauth-tilded.pl"; }; +# Non-forcing IAuth stub with statistics/config support (tests/iauth/). +IAuth { program = "/opt/ircu/bin/iauth-test.pl"; }; + +# Test clients connect from the docker bridge gateway. +WebIRC { + ip = "10.55.0.0/24"; + password = "webircpass"; + description = "test gateway"; +}; Operator { local = no; @@ -56,9 +66,13 @@ Operator { Port { server = yes; port = 4402; }; Port { port = 6669; }; +Port { port = 6691; webirc = yes; }; Features { "NODNS" = "TRUE"; + "MAXCHANNELSPERUSER" = "40"; +# The ident probe to the docker host may be dropped; do not wait the default 9s. + "AUTH_TIMEOUT" = "2"; "CONFIG_OPERCMDS" = "TRUE"; "CAP_MESSAGE_TAGS" = "TRUE"; "CAP_SERVER_TIME" = "TRUE"; diff --git a/tests/features/__init__.py b/tests/features/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/features/test_boolean_features.py b/tests/features/test_boolean_features.py new file mode 100644 index 00000000..66d215c3 --- /dev/null +++ b/tests/features/test_boolean_features.py @@ -0,0 +1,99 @@ +"""Feature handling changes: + +* 170d288 — Boolean features accept "0" and "1". +* d7d9b5f — HIS_REMOTE is a Boolean feature (and gates remote queries). +* 87e808d — OPLEVELS and ZANNELS default to FALSE. +* 22c02d1 — MAXIMUM_LINKS no longer exists. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import drain, set_feature + +pytestmark = pytest.mark.multi_server + + +async def _get(oper, name): + await drain(oper, 0.2) # discard any pending SET/RESET replies + await oper.send(f"GET {name}") + msg = await oper.wait_for("284", timeout=10.0) + return msg.params[-1] + + +async def _set(oper, name, value): + await set_feature(oper, name, value) + + +async def test_his_remote_is_boolean(oper): + assert await _get(oper, "HIS_REMOTE") == "Boolean value of HIS_REMOTE: TRUE" + + +async def test_boolean_accepts_0_and_1(oper): + try: + await _set(oper, "HIS_REMOTE", "0") + assert await _get(oper, "HIS_REMOTE") == "Boolean value of HIS_REMOTE: FALSE" + await _set(oper, "HIS_REMOTE", "1") + assert await _get(oper, "HIS_REMOTE") == "Boolean value of HIS_REMOTE: TRUE" + finally: + await _set(oper, "HIS_REMOTE", "TRUE") + + +async def test_his_remote_gates_remote_queries(ircd_network, oper, make_client): + """Both the local and the target server check HIS_REMOTE in hunt_server_cmd().""" + from cap_helpers import oper_up + + leaf = ircd_network["leaf1"] + leaf_oper = await make_client("hisleafop", host=leaf["host"], port=leaf["port"]) + await oper_up(leaf_oper) + + user = await make_client("hisrem1") + await user.send("TIME leaf1.test.net") + err = await user.wait_for("481", timeout=5.0) + assert err.command == "481" + + await _set(oper, "HIS_REMOTE", "0") + try: + # Hub forwards now, but leaf1 still refuses (its own HIS_REMOTE). + await user.send("TIME leaf1.test.net") + err = await user.wait_for("481", timeout=5.0) + assert err.command == "481" + + await _set(leaf_oper, "HIS_REMOTE", "0") + await user.send("TIME leaf1.test.net") + reply = await user.wait_for("391", timeout=5.0) + assert reply.params[1] == "leaf1.test.net", reply.raw + finally: + await _set(oper, "HIS_REMOTE", "1") + await _set(leaf_oper, "HIS_REMOTE", "1") + + await user.send("TIME leaf1.test.net") + err = await user.wait_for("481", timeout=5.0) + assert err.command == "481" + + +async def test_oplevels_and_zannels_default_false(oper): + assert await _get(oper, "OPLEVELS") == "Boolean value of OPLEVELS: FALSE" + assert await _get(oper, "ZANNELS") == "Boolean value of ZANNELS: FALSE" + + +async def test_reset_restores_default(oper): + await _set(oper, "ZANNELS", "1") + try: + assert await _get(oper, "ZANNELS") == "Boolean value of ZANNELS: TRUE" + finally: + await oper.send("RESET ZANNELS") + await oper.wait_for("284", timeout=10.0) # RESET answers on change + assert await _get(oper, "ZANNELS") == "Boolean value of ZANNELS: FALSE" + + +async def test_maximum_links_removed(oper): + await oper.send("GET MAXIMUM_LINKS") + err = await oper.wait_for("493", timeout=5.0) + assert err.params[1] == "MAXIMUM_LINKS", err.raw + await oper.send("SET MAXIMUM_LINKS 5") + err = await oper.wait_for("493", timeout=5.0) + assert err.params[1] == "MAXIMUM_LINKS", err.raw diff --git a/tests/features/test_feature_edge_cases.py b/tests/features/test_feature_edge_cases.py new file mode 100644 index 00000000..77922cf3 --- /dev/null +++ b/tests/features/test_feature_edge_cases.py @@ -0,0 +1,70 @@ +"""Edge cases for feature handling (commits 170d288, d7d9b5f, 87e808d, 22c02d1).""" + +from __future__ import annotations + +import pytest + +from common import drain, get_feature, set_feature + +pytestmark = pytest.mark.single_server + + +@pytest.mark.parametrize("value,expected", [ + ("YES", "TRUE"), ("NO", "FALSE"), ("ON", "TRUE"), ("OFF", "FALSE"), + ("true", "TRUE"), ("false", "FALSE"), ("1", "TRUE"), ("0", "FALSE"), +]) +async def test_boolean_spellings(oper, value, expected): + try: + await set_feature(oper, "JOIN_TARGET", value) + assert await get_feature(oper, "JOIN_TARGET") == expected + finally: + await set_feature(oper, "JOIN_TARGET", "FALSE") + + +async def test_bad_boolean_value_is_rejected(oper): + await drain(oper, 0.2) + await oper.send("SET JOIN_TARGET maybe") + err = await oper.wait_for("494", timeout=5.0) + assert err.params[1] == "maybe" and err.params[2].endswith("JOIN_TARGET"), err.raw + assert await get_feature(oper, "JOIN_TARGET") == "FALSE" + + +async def test_set_without_change_reports_value(oper): + """SET always answers with RPL_FEATURE, even when nothing changed.""" + await drain(oper, 0.2) + await oper.send("SET JOIN_TARGET FALSE") # already FALSE + msg = await oper.wait_for("284", timeout=5.0) + assert msg.params[-1] == "Boolean value of JOIN_TARGET: FALSE" + + +async def test_set_and_get_require_privileges(make_client): + client = await make_client("feat_plain") + await client.send("SET HIS_REMOTE 0") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" + await client.send("GET HIS_REMOTE") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" + + +async def test_his_remote_gates_other_remote_commands(oper, make_client): + """MOTD/ADMIN/VERSION/STATS/LUSERS to a remote server are all gated.""" + user = await make_client("feat_rem") + for cmd in ("MOTD leaf1.test.net", "ADMIN leaf1.test.net", + "VERSION leaf1.test.net", "LUSERS * leaf1.test.net", + "STATS u leaf1.test.net"): + await user.send(cmd) + err = await user.wait_for("481", timeout=5.0) + assert err.command == "481", cmd + + +async def test_reset_unknown_feature(oper): + await drain(oper, 0.2) + await oper.send("RESET MAXIMUM_LINKS") + err = await oper.wait_for("493", timeout=5.0) + assert err.params[1] == "MAXIMUM_LINKS" + + +async def test_get_read_only_feature_reports_value(oper): + """Boolean read-only features (e.g. HIS_STATS_l) are still readable.""" + assert await get_feature(oper, "HIS_STATS_l") in ("TRUE", "FALSE") diff --git a/tests/iauth/__init__.py b/tests/iauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/iauth/test_iauth_edge_cases.py b/tests/iauth/test_iauth_edge_cases.py new file mode 100644 index 00000000..167bde2f --- /dev/null +++ b/tests/iauth/test_iauth_edge_cases.py @@ -0,0 +1,71 @@ +"""IAuth statistics edge cases (commits b71fd1e, 4db6d5d).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from cap_helpers import oper_up + +pytestmark = pytest.mark.multi_server + + +async def _stats_lines(client, args, end_arg, timeout=8.0): + await client.send(f"STATS {args}") + lines = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + msg = await client.recv(timeout=max(0.1, deadline - loop.time())) + if msg.command == "249": + lines.append(msg.params[-1]) + elif msg.command == "219": + assert msg.params[1] == end_arg, msg.raw + return lines + + +async def test_two_opers_requesting_stats_concurrently(ircd_network, make_client): + """The second request is queued and served by a follow-up "? stats2".""" + leaf = ircd_network["leaf2"] + a = await make_client("iae_a", host=leaf["host"], port=leaf["port"]) + b = await make_client("iae_b", host=leaf["host"], port=leaf["port"]) + await oper_up(a) + await oper_up(b) + await a.send("STATS iauth") + await b.send("STATS iauth") + la, lb = await asyncio.gather( + _stats_lines_no_send(a, "iauthstats"), _stats_lines_no_send(b, "iauthstats") + ) + assert any("stats-requests=" in l for l in la) + assert any("stats-requests=" in l for l in lb) + + +async def _stats_lines_no_send(client, end_arg, timeout=8.0): + lines = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + msg = await client.recv(timeout=max(0.1, deadline - loop.time())) + if msg.command == "249": + lines.append(msg.params[-1]) + elif msg.command == "219": + assert msg.params[1] == end_arg, msg.raw + return lines + + +async def test_iauthconf_reports_missing_version(ircd_network, oper): + """The hub's iauth-tilded.pl sends no V line.""" + lines = await _stats_lines(oper, "iauthconf", "iauthconf") + assert any("did not report a version" in l for l in lines), lines + + +async def test_iauth_stats_sync_path_on_hub(ircd_network, oper): + """Without the S policy (hub stub) STATS iauth answers synchronously.""" + lines = await _stats_lines(oper, "iauth", "iauth") + assert isinstance(lines, list) + + +async def test_stats_iauth_remote(ircd_network, oper): + lines = await _stats_lines(oper, "iauth leaf2.test.net", "iauthstats") + assert any("stats-requests=" in l for l in lines), lines diff --git a/tests/iauth/test_iauth_stats.py b/tests/iauth/test_iauth_stats.py new file mode 100644 index 00000000..15087d6b --- /dev/null +++ b/tests/iauth/test_iauth_stats.py @@ -0,0 +1,98 @@ +"""IAuth statistics and configuration reporting (commits b71fd1e, 4db6d5d, +e8c0791, 5dc3f97). + +leaf2 runs tests/docker/iauth-test.pl with policy ARUS (the hub and leaf1 +run the ~-forcing iauth-tilded.pl used by other suites). + +* /STATS iauthconf shows the reported version; "... get" asks the + program for a fresh configuration ("? config") which the next query shows. +* /STATS iauth is asynchronous with the S policy: the server sends + "? stats2", relays each S line as RPL_STATSDEBUG and ends with + RPL_ENDOFSTATS iauthstats when the program sends "s". +* The stub's reply starts with a fragment terminated by a bare CR; ircd + must drop it and continue parsing the following lines. +* The "u" line the server sends carries cli_user()->username (the USER + name with the ~ from the failed ident lookup), not the empty ident result. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from cap_helpers import oper_up + +pytestmark = pytest.mark.multi_server + + +@pytest.fixture +async def leaf_oper(ircd_network, make_client): + leaf = ircd_network["leaf2"] + client = await make_client("iaop", host=leaf["host"], port=leaf["port"], username="iaoper") + await oper_up(client) + return client + + +async def _stats_lines(client, args, end_arg, timeout=8.0): + await client.send(f"STATS {args}") + lines = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + msg = await client.recv(timeout=max(0.1, deadline - loop.time())) + if msg.command == "249": + lines.append(msg.params[-1]) + elif msg.command == "219": + assert msg.params[1] == end_arg, msg.raw + return lines + + +async def test_iauthconf_reports_version(leaf_oper): + lines = await _stats_lines(leaf_oper, "iauthconf", "iauthconf") + assert any("iauth-test 1.0" in l for l in lines), lines + + +async def test_iauthconf_get_requests_fresh_config(leaf_oper): + await _stats_lines(leaf_oper, "iauthconf leaf2.test.net get", "iauthconf") + await asyncio.sleep(0.5) + lines = await _stats_lines(leaf_oper, "iauthconf", "iauthconf") + assert any("policy=ARUS" in l for l in lines), lines + assert any("config-requests=" in l for l in lines), lines + + +async def test_iauth_stats_are_asynchronous(leaf_oper): + lines = await _stats_lines(leaf_oper, "iauth", "iauthstats") + assert any("stats-requests=" in l for l in lines), lines + + +async def test_iauth_stats_second_request_increments(leaf_oper): + first = await _stats_lines(leaf_oper, "iauth", "iauthstats") + second = await _stats_lines(leaf_oper, "iauth", "iauthstats") + n1 = next(int(l.split("stats-requests=")[1]) for l in first if "stats-requests=" in l) + n2 = next(int(l.split("stats-requests=")[1]) for l in second if "stats-requests=" in l) + assert n2 == n1 + 1 + + +async def test_control_character_fragment_is_dropped(leaf_oper): + lines = await _stats_lines(leaf_oper, "iauth", "iauthstats") + assert not any("garbage-fragment" in l for l in lines), lines + assert any("stats-requests=" in l for l in lines), lines + + +async def test_username_line_carries_user_command_name(leaf_oper): + """5dc3f97: "u" is sent with cli_user()->username (the USER name, tilded).""" + lines = await _stats_lines(leaf_oper, "iauth", "iauthstats") + mine = [l for l in lines if "nick=iaop " in l] + assert mine, lines + # Both are reported after the ~ for the failed ident lookup was prepended. + assert "U=~iaoper" in mine[-1], mine[-1] + assert "u=~iaoper" in mine[-1], mine[-1] + + +async def test_iauth_stats_requires_oper(ircd_network, make_client): + leaf = ircd_network["leaf2"] + client = await make_client("iaplain", host=leaf["host"], port=leaf["port"]) + await client.send("STATS iauth") + err = await client.wait_for("481", timeout=5.0) + assert err.command == "481" diff --git a/tests/p10_server.py b/tests/p10_server.py index 7814a458..d7384aa3 100644 --- a/tests/p10_server.py +++ b/tests/p10_server.py @@ -237,6 +237,21 @@ async def handshake(self, timeout: float = 15.0): Sends PASS + SERVER, reads the hub's PASS + SERVER + burst, sends our EB, waits for EA, sends EA. """ + deadline = asyncio.get_event_loop().time() + timeout + await self.begin_handshake(timeout=timeout) + await self.send_end_of_burst() + remaining = deadline - asyncio.get_event_loop().time() + await self.complete_handshake(timeout=remaining) + + async def begin_handshake(self, timeout: float = 15.0): + """Send PASS + SERVER and read the hub's burst up to its EB. + + Leaves the link in the "still bursting" state from the hub's point + of view: we have not sent our own EB yet. Tests that need a + half-linked server (e.g. to simulate a link that dies mid-burst) + stop here; otherwise follow with send_end_of_burst() and + complete_handshake(). + """ now = int(time.time()) # Send our credentials @@ -259,10 +274,13 @@ async def handshake(self, timeout: float = 15.0): if tok == "EB" or line == "EB": break - # Send our (empty) burst + end of burst + async def send_end_of_burst(self): + """Send our EB, marking the end of our (possibly empty) burst.""" await self._send(f"{self._num} EB") - # Wait for EA (end of burst ack) + async def complete_handshake(self, timeout: float = 15.0): + """Wait for the hub's EA and answer with our own EA.""" + deadline = asyncio.get_event_loop().time() + timeout while True: remaining = deadline - asyncio.get_event_loop().time() if remaining <= 0: @@ -329,21 +347,34 @@ async def send_downstream_server( flags: str = "", description: str = "Downstream test server", timestamp: int | None = None, + bursting: bool = True, ) -> str: """Introduce a remote server behind this link. + ``bursting`` selects the protocol field: ``J10`` (default) tells the + hub the server is still bursting -- it stays flagged as such until + an EB arrives from *that* server's numeric (see send_end_of_burst_for). + ``P10`` introduces a server whose burst already completed, which is + how an uplink re-introduces its existing downlinks during its own + burst. + Returns the 2-character P10 server numeric for the new server. """ ts = timestamp or _next_timestamp() down_num = server_numeric(numeric) down_mask = down_num + int_to_b64(self.max_clients, 3) flag_field = f"+{flags}" if flags else "+" + proto = "J10" if bursting else "P10" await self._send( - f"{self._num} SERVER {name} {hop} 0 {ts} J10 {down_mask} " + f"{self._num} SERVER {name} {hop} 0 {ts} {proto} {down_mask} " f"{flag_field} :{description}" ) return down_num + async def send_end_of_burst_for(self, server_numeric_prefix: str): + """Send EB on behalf of a downstream server introduced with J10.""" + await self._send(f"{server_numeric_prefix} EB") + async def send_downstream_nick( self, server_numeric_prefix: str, diff --git a/tests/relay/__init__.py b/tests/relay/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/relay/test_cprivmsg_idle.py b/tests/relay/test_cprivmsg_idle.py new file mode 100644 index 00000000..0aa6bc52 --- /dev/null +++ b/tests/relay/test_cprivmsg_idle.py @@ -0,0 +1,52 @@ +"""CPRIVMSG resets the sender's idle time like PRIVMSG (commit c61b856).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import drain, join, sender_nick, wait_for_join, whois + +pytestmark = pytest.mark.single_server + + +async def _idle(client) -> int: + """Own idle time; HIS_WHOIS_IDLETIME hides other users' idle from non-opers.""" + replies = await whois(client, client.nick) + assert "317" in replies, sorted(replies) + return int(replies["317"].params[2]) + + +async def test_cprivmsg_resets_idle(make_client): + chan = "#cp_idle" + op = await make_client("cpop1") + peer = await make_client("cppeer1") + await join(op, chan) + await join(peer, chan) + await wait_for_join(op, chan, "cppeer1") + await asyncio.sleep(3.2) + assert await _idle(op) >= 3 + + await op.send(f"CPRIVMSG cppeer1 {chan} :quiet hello") + msg = await peer.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert sender_nick(msg) == "cpop1" and msg.params[-1] == "quiet hello" + assert await _idle(op) <= 1 + + +async def test_cnotice_does_not_reset_idle(make_client): + """Only CPRIVMSG got the idle reset; CNOTICE is unchanged (control).""" + chan = "#cn_idle" + op = await make_client("cnop2") + peer = await make_client("cnpeer2") + await join(op, chan) + await join(peer, chan) + await wait_for_join(op, chan, "cnpeer2") + await asyncio.sleep(3.2) + before = await _idle(op) + assert before >= 3 + + await op.send(f"CNOTICE cnpeer2 {chan} :quiet notice") + msg = await peer.wait_for_user_msg("NOTICE", timeout=5.0) + assert msg.params[-1] == "quiet notice" + assert await _idle(op) >= before diff --git a/tests/relay/test_directed_notice.py b/tests/relay/test_directed_notice.py new file mode 100644 index 00000000..9d6ae178 --- /dev/null +++ b/tests/relay/test_directed_notice.py @@ -0,0 +1,67 @@ +"""NOTICE nick@server follows the PRIVMSG nick@server rules (commits 0039f1f, +9f28062). + +Only service servers (SERVER flag +s) may be addressed with nick@server. +For a non-service server, or an unknown server, the sender gets +ERR_NOSUCHNICK and nothing is delivered. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.single_server + + +async def test_notice_to_local_non_service_user_is_refused(make_client): + sender = await make_client("dn1") + target = await make_client("dnt1") + await sender.send("NOTICE dnt1@hub.test.net :are you there") + err = await sender.wait_for("401", timeout=5.0) + assert err.params[1] == "dnt1@hub.test.net", err.raw + await target.assert_no_message("NOTICE", timeout=1.0) + + +async def test_privmsg_to_local_non_service_user_is_refused(make_client): + """Same rule for PRIVMSG (pre-existing behaviour, kept in sync).""" + sender = await make_client("dn2") + target = await make_client("dnt2") + await sender.send("PRIVMSG dnt2@hub.test.net :are you there") + err = await sender.wait_for("401", timeout=5.0) + assert err.params[1] == "dnt2@hub.test.net", err.raw + await target.assert_no_message("PRIVMSG", timeout=1.0) + + +async def test_notice_to_unknown_server_is_refused(make_client): + sender = await make_client("dn3") + await sender.send("NOTICE somebody@no.such.server :hi") + err = await sender.wait_for("401", timeout=5.0) + assert err.params[1] == "somebody@no.such.server", err.raw + + +async def test_notice_to_service_server_is_forwarded(make_client, ulined_server): + await ulined_server.introduce_user("DnSvc", modes="+ik") + sender = await make_client("dn4") + await sender.send("NOTICE DnSvc@services.test.net :hello service") + line = await ulined_server.wait_for_token("O", timeout=5.0) + assert "DnSvc@services.test.net :hello service" in line, line + await sender.assert_no_message("401", timeout=1.0) + + +async def test_privmsg_to_service_server_is_forwarded(make_client, ulined_server): + await ulined_server.introduce_user("DpSvc", modes="+ik") + sender = await make_client("dn5") + await sender.send("PRIVMSG DpSvc@services.test.net :hello service") + line = await ulined_server.wait_for_token("P", timeout=5.0) + assert "DpSvc@services.test.net :hello service" in line, line + + +@pytest.mark.multi_server +async def test_notice_to_user_on_non_service_leaf_is_refused(ircd_network, make_client): + leaf = ircd_network["leaf1"] + target = await make_client("dnt6", host=leaf["host"], port=leaf["port"]) + sender = await make_client("dn6") + await sender.send("NOTICE dnt6@leaf1.test.net :leaf?") + err = await sender.wait_for("401", timeout=5.0) + assert err.params[1] == "dnt6@leaf1.test.net", err.raw + await target.assert_no_message("NOTICE", timeout=1.0) diff --git a/tests/relay/test_join_target.py b/tests/relay/test_join_target.py new file mode 100644 index 00000000..e3de887c --- /dev/null +++ b/tests/relay/test_join_target.py @@ -0,0 +1,99 @@ +"""Joining many channels at once (commits 5ffe0a1, 54cfd56). + +With FEAT_JOIN_TARGET=FALSE (default) a JOIN never fails because of the +target-change limit: the membership is flagged "delayed target" instead. +With FEAT_JOIN_TARGET=TRUE the historical behaviour applies and joins +beyond the free-target budget are refused with ERR_TARGETTOOFAST. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import collect, set_feature + +pytestmark = pytest.mark.single_server + +# Well above STARTTARGETS (10) and MAXTARGETS (20); below MAXCHANNELSPERUSER (40). +CHANNELS = 25 + + +async def _burst_join(client, prefix): + chans = [f"#{prefix}{i}" for i in range(CHANNELS)] + # One JOIN command so the per-command flood penalty does not slow us down. + await client.send("JOIN " + ",".join(chans)) + joined = set() + tfast = 0 + for msg in await collect(client, 3.0): + if msg.command == "JOIN" and msg.prefix and msg.prefix.split("!")[0] == client.nick: + joined.add(msg.params[0].lower()) + elif msg.command == "439": + tfast += 1 + return chans, joined, tfast + + +async def test_join_burst_not_limited_by_default(make_client): + client = await make_client("jtburst1") + chans, joined, _ = await _burst_join(client, "jt_free") + assert joined == {c.lower() for c in chans}, ( + f"only {len(joined)}/{CHANNELS} channels joined: missing " + f"{sorted(set(c.lower() for c in chans) - joined)}" + ) + + +async def test_join_burst_limited_with_JOIN_TARGET(make_client, oper): + """With JOIN_TARGET=TRUE the same burst is cut short by the target limit. + + Recent targets are remembered as a byte hash of the channel pointer and + inherited by the next connection from the same IP, so channels that were + freed just before would be "known" targets once their memory is reused. + The unrestricted client therefore stays connected (keeping its channels + alive) while the restricted client joins a fresh set. + """ + free_client = await make_client("jtfree2") + chans, joined, _ = await _burst_join(free_client, "jt_free2_") + assert len(joined) == CHANNELS + + await set_feature(oper, "JOIN_TARGET", "TRUE") + try: + client = await make_client("jtburst2") + chans, joined, tfast = await _burst_join(client, "jt_strict") + assert len(joined) < CHANNELS, "JOIN_TARGET=TRUE should refuse some joins" + assert tfast >= 1, "expected ERR_TARGETTOOFAST for refused joins" + finally: + await set_feature(oper, "JOIN_TARGET", "FALSE") + + # And the default is restored. + client = await make_client("jtburst3") + chans, joined, _ = await _burst_join(client, "jt_after") + assert len(joined) == CHANNELS + + +async def test_messaging_after_join_burst(make_client): + """Channels joined past the target budget are still usable.""" + speaker = await make_client("jtspk4") + listener = await make_client("jtlst4") + chans, joined, _ = await _burst_join(speaker, "jt_msg") + assert len(joined) == CHANNELS + last = chans[-1] + await listener.send(f"JOIN {last}") + await listener.wait_for("JOIN") + await asyncio.sleep(0.3) + await speaker.send(f"PRIVMSG {last} :still works") + msg = await listener.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert msg.params[-1] == "still works" + + +async def test_invited_user_gets_free_target(make_client): + """check_target_limit() still grants a free target for invited channels.""" + op = await make_client("jtop5") + invitee = await make_client("jtinv5") + await op.send("JOIN #jt_invite") + await op.wait_for("JOIN") + await op.send("INVITE jtinv5 #jt_invite") + await invitee.wait_for_user_msg("INVITE", timeout=5.0) + await invitee.send("JOIN #jt_invite") + msg = await invitee.wait_for("JOIN", timeout=5.0) + assert msg.params[0].lower() == "#jt_invite" diff --git a/tests/relay/test_relay_edge_cases.py b/tests/relay/test_relay_edge_cases.py new file mode 100644 index 00000000..b62c04c1 --- /dev/null +++ b/tests/relay/test_relay_edge_cases.py @@ -0,0 +1,102 @@ +"""Edge cases for nick@server relaying, JOIN target limits and CPRIVMSG +(commits 0039f1f, 5ffe0a1, 54cfd56, c61b856).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import collect, drain, join, set_feature, wait_for_join + +pytestmark = pytest.mark.single_server + + +async def test_notice_with_host_qualifier_to_service(make_client, ulined_server): + """nick%host@server keeps the full target when forwarded to a service.""" + await ulined_server.introduce_user("QualSvc", modes="+ik", host="svc.host") + sender = await make_client("rel_q1") + await sender.send("NOTICE QualSvc%svc.host@services.test.net :qualified") + line = await ulined_server.wait_for_token("O", timeout=5.0) + assert "QualSvc%svc.host@services.test.net :qualified" in line, line + + +async def test_directed_notice_rules_apply_to_opers(oper, make_client): + target = await make_client("rel_t2") + await oper.send("NOTICE rel_t2@hub.test.net :oper says hi") + err = await oper.wait_for("401", timeout=5.0) + assert err.params[1] == "rel_t2@hub.test.net" + await target.assert_no_message("NOTICE", timeout=1.0) + + +async def test_directed_notice_to_nonexistent_service_user(make_client, ulined_server): + """Forwarding to a service server does not check that the nick exists.""" + sender = await make_client("rel_s3") + await sender.send("NOTICE Nobody@services.test.net :anyone?") + line = await ulined_server.wait_for_token("O", timeout=5.0) + assert "Nobody@services.test.net :anyone?" in line + await sender.assert_no_message("401", timeout=1.0) + + +async def test_directed_privmsg_silence_does_not_apply_remotely(make_client, ulined_server): + """is_silenced() is only consulted for local delivery.""" + await ulined_server.introduce_user("SilSvc", modes="+ik") + sender = await make_client("rel_s4") + await sender.send("PRIVMSG SilSvc@services.test.net :hello") + line = await ulined_server.wait_for_token("P", timeout=5.0) + assert "SilSvc@services.test.net :hello" in line + + +async def test_rejoining_same_channel_is_not_a_new_target(make_client, oper): + await set_feature(oper, "JOIN_TARGET", "TRUE") + try: + client = await make_client("rel_jt6") + chan = "#rel_jt6_same" + for _ in range(5): + await join(client, chan) + await client.send(f"PART {chan}") + await client.wait_for("PART", timeout=5.0) + await client.assert_no_message("439", timeout=0.5) + finally: + await set_feature(oper, "JOIN_TARGET", "FALSE") + + +@pytest.mark.multi_server +async def test_join_burst_from_leaf_user_not_limited(ircd_network, make_client): + leaf = ircd_network["leaf1"] + client = await make_client("rel_jt7", host=leaf["host"], port=leaf["port"]) + chans = [f"#rel_jt7_{i}" for i in range(15)] + await client.send("JOIN " + ",".join(chans)) + msgs = await collect(client, 3.0) + joined = {m.params[0].lower() for m in msgs if m.command == "JOIN"} + assert joined == {c.lower() for c in chans} + + +async def test_cprivmsg_requires_op_or_voice(make_client): + chan = "#rel_cp8" + op = await make_client("rel_op8") + peon = await make_client("rel_peon8") + await join(op, chan) + await join(peon, chan) + await peon.send(f"CPRIVMSG rel_op8 {chan} :may I?") + err = await peon.wait_for("489", timeout=5.0) + assert err.params[1].lower() == chan + await op.assert_no_message("PRIVMSG", timeout=0.5) + + +async def test_cprivmsg_target_must_be_on_channel(make_client): + chan = "#rel_cp9" + op = await make_client("rel_op9") + other = await make_client("rel_other9") + await join(op, chan) + await op.send(f"CPRIVMSG rel_other9 {chan} :you there?") + err = await op.wait_for("441", timeout=5.0) + assert err.params[1] == "rel_other9" and err.params[2].lower() == chan + await other.assert_no_message("PRIVMSG", timeout=0.5) + + +async def test_cprivmsg_needs_all_parameters(make_client): + op = await make_client("rel_op10") + await op.send("CPRIVMSG rel_op10 #rel_cp10") + err = await op.wait_for("461", timeout=5.0) + assert err.params[1] == "CPRIVMSG" diff --git a/tests/s2s/__init__.py b/tests/s2s/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/s2s/test_gline_reason.py b/tests/s2s/test_gline_reason.py new file mode 100644 index 00000000..5f5fcfcb --- /dev/null +++ b/tests/s2s/test_gline_reason.py @@ -0,0 +1,82 @@ +"""G-line updates from servers (commits 37a02bc, 8375a80). + +A six-parameter GLINE for an existing G-line carries either a new lifetime +(all digits) or a new reason. 37a02bc fixed the trial conversion so a +textual reason is applied; 8375a80 makes gline_modify() ignore a reason +update without a reason. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +pytestmark = pytest.mark.single_server + +MASK = "*@192.0.2.*" + + +async def _glist(oper, mask): + await oper.send(f"GLINE {mask}") + msgs = await oper.collect_until("281", timeout=5.0) + return [m for m in msgs if m.command == "280"] + + +async def _reason(oper, mask): + entries = await _glist(oper, mask) + assert len(entries) == 1, [m.raw for m in entries] + return entries[0].params[-1] + + +async def _lifetime(oper, mask): + entries = await _glist(oper, mask) + assert len(entries) == 1 + return int(entries[0].params[4]) + + +async def test_server_gline_reason_update(oper, ulined_server): + num = ulined_server.server_numnick + lastmod = int(time.time()) + await ulined_server._send(f"{num} GL * +{MASK} 3600 {lastmod} :reason one") + await asyncio.sleep(0.3) + try: + assert await _reason(oper, MASK) == "reason one" + + # Six parameters, non-numeric fifth => reason update. + await ulined_server._send(f"{num} GL * +{MASK} 3600 {lastmod + 1} :reason two") + await asyncio.sleep(0.3) + assert await _reason(oper, MASK) == "reason two" + + # Six parameters, all-digit fifth => lifetime (absolute) update, + # reason untouched. + new_lifetime = lastmod + 7200 + await ulined_server._send(f"{num} GL * +{MASK} 3600 {lastmod + 2} {new_lifetime}") + await asyncio.sleep(0.3) + assert await _reason(oper, MASK) == "reason two" + assert await _lifetime(oper, MASK) == new_lifetime + + # Numeric-looking reason must still be treated as a reason. + await ulined_server._send(f"{num} GL * +{MASK} 3600 {lastmod + 3} :12345 not a lifetime") + await asyncio.sleep(0.3) + assert await _reason(oper, MASK) == "12345 not a lifetime" + finally: + await ulined_server._send(f"{num} GL * -{MASK} {lastmod + 10}") + await asyncio.sleep(0.3) + + +async def test_stale_update_is_ignored(oper, ulined_server): + """An update with an older lastmod does not change the reason.""" + num = ulined_server.server_numnick + mask = "*@192.0.3.*" + lastmod = int(time.time()) + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod} :current") + await asyncio.sleep(0.3) + try: + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod - 100} :stale") + await asyncio.sleep(0.3) + assert await _reason(oper, mask) == "current" + finally: + await ulined_server._send(f"{num} GL * -{mask} {lastmod + 10}") + await asyncio.sleep(0.3) diff --git a/tests/s2s/test_parse.py b/tests/s2s/test_parse.py new file mode 100644 index 00000000..59771bdc --- /dev/null +++ b/tests/s2s/test_parse.py @@ -0,0 +1,96 @@ +"""Server-to-server parser robustness. + +* 30d035b — a numeric outside 001..999 from a server is dropped instead of + being dispatched. +* 9cad720 / a7bc0ee — msg_tree_parse() accepts '_' so full-name tokens such + as END_OF_BURST are recognised again. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import whois +from p10_server import P10Server + +pytestmark = pytest.mark.single_server + + +async def _link(hub, name="notulined.test.net", numeric=5) -> P10Server: + srv = P10Server(name=name, numeric=numeric, password="testpass", server_flags="") + await srv.connect(hub["host"], hub["server_port"]) + return srv + + +async def test_invalid_numeric_is_ignored(ircd_hub, make_client): + srv = await _link(ircd_hub) + try: + await srv.handshake() + await srv._send(f"{srv.server_numnick} 000 AB :bogus numeric") + await asyncio.sleep(0.3) + # The link must still be alive and processing messages. + await srv.introduce_user("parseok1", realname="Parse OK") + client = await make_client("parsecli1") + replies = await whois(client, "parseok1") + assert "311" in replies, replies + assert replies["311"].params[1] == "parseok1" + finally: + await srv.disconnect() + + +async def test_valid_numeric_is_relayed(ircd_hub, make_client): + """Control: a numeric addressed to a local user is delivered.""" + client = await make_client("parsecli2") + srv = await _link(ircd_hub) + try: + await srv.handshake() + numnick = await srv.wait_for_user("parsecli2") + await srv._send(f"{srv.server_numnick} 391 {numnick} notulined.test.net 0 0 :fake time") + reply = await client.wait_for("391", timeout=5.0) + assert reply.params[-1] == "fake time", reply.raw + finally: + await srv.disconnect() + + +async def test_end_of_burst_full_token_name(ircd_hub): + """END_OF_BURST spelled out (instead of EB) completes the handshake.""" + srv = await _link(ircd_hub) + try: + await srv.begin_handshake() + await srv._send(f"{srv.server_numnick} END_OF_BURST") + await srv.complete_handshake(timeout=10.0) + assert srv.burst_complete + finally: + await srv.disconnect() + + +async def test_end_of_burst_ack_full_token_name(ircd_hub, make_client): + """END_OF_BURST_ACK spelled out is accepted like EA.""" + srv = await _link(ircd_hub) + try: + await srv.begin_handshake() + await srv.send_end_of_burst() + await srv.recv_until("EA", timeout=10.0) + await srv._send(f"{srv.server_numnick} END_OF_BURST_ACK") + srv.burst_complete = True + await srv.introduce_user("parseok4", realname="Parse OK") + client = await make_client("parsecli4") + replies = await whois(client, "parseok4") + assert "311" in replies, replies + finally: + await srv.disconnect() + + +async def test_unknown_underscore_command_is_ignored(ircd_hub, make_client): + srv = await _link(ircd_hub) + try: + await srv.handshake() + await srv._send(f"{srv.server_numnick} NO_SUCH_COMMAND foo :bar") + await asyncio.sleep(0.3) + await srv.introduce_user("parseok5", realname="Parse OK") + client = await make_client("parsecli5") + assert "311" in await whois(client, "parseok5") + finally: + await srv.disconnect() diff --git a/tests/s2s/test_s2s_edge_cases.py b/tests/s2s/test_s2s_edge_cases.py new file mode 100644 index 00000000..08941c9e --- /dev/null +++ b/tests/s2s/test_s2s_edge_cases.py @@ -0,0 +1,142 @@ +"""Edge cases for the server parser and G-line updates (commits 30d035b, +9cad720, 37a02bc, 8375a80).""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from common import whois +from p10_server import P10Server + +pytestmark = pytest.mark.single_server + + +async def _link(hub, name="notulined.test.net", numeric=5) -> P10Server: + srv = P10Server(name=name, numeric=numeric, password="testpass", server_flags="") + await srv.connect(hub["host"], hub["server_port"]) + await srv.handshake() + return srv + + +async def _alive(srv, make_client, nick): + await srv.introduce_user(nick, realname="Alive") + client = await make_client(nick + "c") + assert "311" in await whois(client, nick) + + +async def test_numeric_999_to_local_user(ircd_hub, make_client): + client = await make_client("s2se_1") + srv = await _link(ircd_hub) + try: + numnick = await srv.wait_for_user("s2se_1") + await srv._send(f"{srv.server_numnick} 999 {numnick} :edge numeric") + reply = await client.wait_for("999", timeout=5.0) + assert reply.params[-1] == "edge numeric" + finally: + await srv.disconnect() + + +async def test_numeric_to_unknown_target_is_ignored(ircd_hub, make_client): + srv = await _link(ircd_hub) + try: + await srv._send(f"{srv.server_numnick} 391 ZZZZZ :nobody") + await asyncio.sleep(0.2) + await _alive(srv, make_client, "s2se_2") + finally: + await srv.disconnect() + + +async def test_mixed_digit_letter_token_is_not_a_numeric(ircd_hub, make_client): + srv = await _link(ircd_hub) + try: + await srv._send(f"{srv.server_numnick} 00A AB :not numeric") + await srv._send(f"{srv.server_numnick} 0_1 AB :not numeric") + await asyncio.sleep(0.2) + await _alive(srv, make_client, "s2se_3") + finally: + await srv.disconnect() + + +async def test_empty_and_whitespace_lines_are_ignored(ircd_hub, make_client): + srv = await _link(ircd_hub) + try: + await srv._send("") + await srv._send(" ") + await srv._send(f"{srv.server_numnick}") + await asyncio.sleep(0.2) + await _alive(srv, make_client, "s2se_4") + finally: + await srv.disconnect() + + +async def test_gline_seven_param_form_updates_lifetime_and_reason(oper, ulined_server): + num = ulined_server.server_numnick + mask = "*@192.0.4.*" + lastmod = int(time.time()) + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod} :seven one") + await asyncio.sleep(0.3) + try: + life = lastmod + 9000 + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod + 1} {life} :seven two") + await asyncio.sleep(0.3) + await oper.send(f"GLINE {mask}") + msgs = await oper.collect_until("281", timeout=5.0) + entries = [m for m in msgs if m.command == "280"] + assert len(entries) == 1, [m.raw for m in msgs] + assert entries[0].params[-1] == "seven two" + assert int(entries[0].params[4]) == life + finally: + await ulined_server._send(f"{num} GL * -{mask} {lastmod + 10}") + await asyncio.sleep(0.3) + + +async def test_gline_deactivate_and_reactivate(oper, ulined_server): + num = ulined_server.server_numnick + mask = "*@192.0.5.*" + lastmod = int(time.time()) + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod} :toggle") + await asyncio.sleep(0.3) + + async def state(): + await oper.send(f"GLINE {mask}") + msgs = await oper.collect_until("281", timeout=5.0) + entries = [m for m in msgs if m.command == "280"] + assert len(entries) == 1, [m.raw for m in msgs] + return entries[0] + + try: + e = await state() + assert e.params[-1] == "toggle" + await ulined_server._send(f"{num} GL * -{mask} {lastmod + 1}") + await asyncio.sleep(0.3) + e = await state() + assert "-" in e.params[5:8] or e.params[5].startswith("-") or "-" in "".join(e.params[5:8]), e.raw + await ulined_server._send(f"{num} GL * +{mask} {lastmod + 2}") + await asyncio.sleep(0.3) + e = await state() + assert "+" in "".join(e.params[5:8]), e.raw + finally: + await ulined_server._send(f"{num} GL * -{mask} {lastmod + 10}") + await asyncio.sleep(0.3) + + +async def test_gline_reason_with_leading_digits_kept(oper, ulined_server): + """A reason such as "3 strikes" must not be parsed as a lifetime.""" + num = ulined_server.server_numnick + mask = "*@192.0.6.*" + lastmod = int(time.time()) + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod} :first") + await asyncio.sleep(0.3) + try: + await ulined_server._send(f"{num} GL * +{mask} 3600 {lastmod + 1} :3 strikes") + await asyncio.sleep(0.3) + await oper.send(f"GLINE {mask}") + msgs = await oper.collect_until("281", timeout=5.0) + entries = [m for m in msgs if m.command == "280"] + assert entries and entries[0].params[-1] == "3 strikes" + finally: + await ulined_server._send(f"{num} GL * -{mask} {lastmod + 10}") + await asyncio.sleep(0.3) diff --git a/tests/username/__init__.py b/tests/username/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/username/test_ident_username.py b/tests/username/test_ident_username.py new file mode 100644 index 00000000..cfb52f93 --- /dev/null +++ b/tests/username/test_ident_username.py @@ -0,0 +1,142 @@ +"""Username handling at registration. + +* adcd438 — ident lookups happen only when some Client block carries a + username mask (DoIdentLookups): the server announces "Checking Ident" + and prefixes ~ when the lookup fails. leaf1 has no username mask (no + lookup); the hub and leaf2 have one. +* 85f4db0 — a WEBIRC-spoofed client's USER name is trusted verbatim even + though ident lookups are on (leaf2, WebIRC port). + +The hub and leaf1 run iauth-tilded.pl, which forces a ~ regardless, so the +~ assertions for the ident path use leaf2 (non-forcing IAuth stub). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import whois +from irc_client import IRCClient + +pytestmark = pytest.mark.multi_server + + +async def _register(server, nick, username, port=None, pre=None): + client = IRCClient() + await client.connect(server["host"], port or server["port"]) + if pre: + await client.send(pre) + await client.register(nick, username, "Ident Test") + return client + + +async def _auth_notices(server, port=None, wait=1.5) -> list[str]: + """Collect the NOTICE AUTH lines sent while a connection is pending.""" + client = IRCClient() + await client.connect(server["host"], port or server["port"]) + out = [] + deadline = asyncio.get_running_loop().time() + wait + try: + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + try: + msg = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + break + if msg.command == "NOTICE" and msg.params[0] == "AUTH": + out.append(msg.params[-1]) + finally: + await client.disconnect() + return out + + +async def test_ident_is_queried_only_with_username_mask(ircd_network): + """DoIdentLookups is set by a Client block with a username (adcd438).""" + assert any("Checking Ident" in n for n in await _auth_notices(ircd_network["hub"])) + assert any("Checking Ident" in n for n in await _auth_notices(ircd_network["leaf2"])) + assert not any("Ident" in n for n in await _auth_notices(ircd_network["leaf1"])) + + +async def test_failed_ident_gets_tilde_with_username_mask(ircd_network): + """leaf2: Client { username = "*" } => ident is queried; failure adds ~.""" + client = await _register(ircd_network["leaf2"], "identleaf2", "leafuser") + try: + replies = await whois(client, "identleaf2") + assert replies["311"].params[2] == "~leafuser", replies["311"].raw + finally: + await client.disconnect() + + +async def test_webirc_client_username_is_trusted(ircd_network): + leaf = ircd_network["leaf2"] + client = await _register( + leaf, "identweb3", "webuser", port=leaf["webirc_port"], + pre="WEBIRC webircpass gateway spoofed.example.net 192.0.2.7", + ) + try: + replies = await whois(client, "identweb3") + assert replies["311"].params[2] == "webuser", replies["311"].raw + assert replies["311"].params[3] == "spoofed.example.net", replies["311"].raw + finally: + await client.disconnect() + + +async def test_webirc_client_skips_ident(ircd_network): + """A spoofed client never sees the ident probe (auth_spoof_user path).""" + leaf = ircd_network["leaf2"] + client = IRCClient() + await client.connect(leaf["host"], leaf["webirc_port"]) + await client.send("WEBIRC webircpass gateway spoofed.example.net 192.0.2.9") + await client.register("identweb9", "webuser", "x") + try: + notices = [m.params[-1] for m in client.received_messages + if m.command == "NOTICE" and m.params[0] == "AUTH"] + assert not any("Ident" in n for n in notices), notices + finally: + await client.disconnect() + + +async def test_webirc_port_requires_webirc_first(ircd_network): + """NICK/USER on a WebIRC port before WEBIRC closes the link (m_nick/m_user).""" + leaf = ircd_network["leaf2"] + client = IRCClient() + await client.connect(leaf["host"], leaf["webirc_port"]) + with pytest.raises(ConnectionError): + await client.register("identweb4", "plainuser", "x") + await client.disconnect() + + +async def test_webirc_bad_password_is_rejected(ircd_network): + leaf = ircd_network["leaf2"] + client = IRCClient() + await client.connect(leaf["host"], leaf["webirc_port"]) + await client.send("WEBIRC wrongpass gateway spoofed.example.net 192.0.2.8") + with pytest.raises(ConnectionError): + await client.register("identweb5", "webuser", "x") + await client.disconnect() + + +async def test_webirc_on_normal_port_is_rejected(ircd_network): + """WEBIRC is only accepted on ports flagged webirc = yes.""" + leaf = ircd_network["leaf2"] + client = IRCClient() + await client.connect(leaf["host"], leaf["port"]) + await client.send("WEBIRC webircpass gateway spoofed.example.net 192.0.2.10") + with pytest.raises(ConnectionError): + await client.register("identweb6", "webuser", "x") + await client.disconnect() + + +async def test_webirc_invalid_spoof_host_is_rejected(ircd_network): + """auth_verify_hostname() failure => "WEBIRC invalid spoof".""" + leaf = ircd_network["leaf2"] + client = IRCClient() + await client.connect(leaf["host"], leaf["webirc_port"]) + await client.send("WEBIRC webircpass gateway bad host!name 192.0.2.11") + with pytest.raises(ConnectionError): + await client.register("identweb7", "webuser", "x") + await client.disconnect() diff --git a/tests/username/test_strict_digit_groups.py b/tests/username/test_strict_digit_groups.py new file mode 100644 index 00000000..578d1e48 --- /dev/null +++ b/tests/username/test_strict_digit_groups.py @@ -0,0 +1,81 @@ +"""STRICT_USERNAME two-digit-group rule (commit cbe68ab). + +With STRICT_USERNAME on, a username with exactly two groups of digits is +allowed when one of the groups is at the start or the end. The check used +to look at a stale loop variable, so "ab12cd34" was wrongly rejected. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import set_feature +from irc_client import IRCClient + +pytestmark = pytest.mark.single_server + + +async def _try_register(host, port, nick, username) -> tuple[bool, str]: + client = IRCClient() + await client.connect(host, port) + try: + await client.send(f"NICK {nick}") + await client.send(f"USER {username} 0 * :User {nick}") + deadline = asyncio.get_running_loop().time() + 8.0 + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return False, "timeout" + try: + msg = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError) as exc: + return False, f"disconnect:{exc}" + if msg.command in ("376", "422"): + await client.send("QUIT :done") + return True, "registered" + if msg.command == "468" or "invalid" in msg.raw.lower(): + return False, msg.raw + finally: + try: + await client.disconnect() + except Exception: + pass + + +STRICT_ACCEPT = [ + ("ab12cd34", "two digit groups, one at the end"), + ("12ab34cd", "two digit groups, one at the start"), + ("ab12cd", "single digit group"), + ("user99", "trailing digits"), +] +STRICT_REJECT = [ + ("ab12cd34x", "two digit groups, neither at start nor end"), + ("a1b2c3", "three digit groups"), +] + + +@pytest.fixture +async def strict(oper): + await set_feature(oper, "STRICT_USERNAME", "TRUE") + yield + await set_feature(oper, "STRICT_USERNAME", "FALSE") + + +async def test_strict_accepts_edge_digit_groups(ircd_hub, strict): + for i, (username, why) in enumerate(STRICT_ACCEPT): + ok, detail = await _try_register(ircd_hub["host"], ircd_hub["port"], f"sdok{i}", username) + assert ok, f"{username!r} ({why}) should be accepted: {detail}" + + +async def test_strict_rejects_middle_digit_groups(ircd_hub, strict): + for i, (username, why) in enumerate(STRICT_REJECT): + ok, detail = await _try_register(ircd_hub["host"], ircd_hub["port"], f"sdbad{i}", username) + assert not ok, f"{username!r} ({why}) should be rejected" + + +async def test_default_accepts_everything_above(ircd_hub): + for i, (username, _) in enumerate(STRICT_ACCEPT + STRICT_REJECT): + ok, detail = await _try_register(ircd_hub["host"], ircd_hub["port"], f"sddef{i}", username) + assert ok, f"{username!r} should be accepted with STRICT_USERNAME off: {detail}" diff --git a/tests/username/test_username_edge_cases.py b/tests/username/test_username_edge_cases.py new file mode 100644 index 00000000..ed0a7a96 --- /dev/null +++ b/tests/username/test_username_edge_cases.py @@ -0,0 +1,69 @@ +"""STRICT_USERNAME edge cases around the rules touched by 85c37ef/cbe68ab.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from common import set_feature +from irc_client import IRCClient + +pytestmark = pytest.mark.single_server + + +async def _try_register(host, port, nick, username) -> bool: + client = IRCClient() + await client.connect(host, port) + try: + await client.send(f"NICK {nick}") + await client.send(f"USER {username} 0 * :User {nick}") + deadline = asyncio.get_running_loop().time() + 8.0 + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return False + try: + msg = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return False + if msg.command in ("376", "422"): + await client.send("QUIT :done") + return True + if msg.command == "468": + return False + finally: + try: + await client.disconnect() + except Exception: + pass + + +@pytest.fixture +async def strict(oper): + await set_feature(oper, "STRICT_USERNAME", "TRUE") + yield + await set_feature(oper, "STRICT_USERNAME", "FALSE") + + +@pytest.mark.parametrize("username,ok", [ + ("ABCdef", True), # up to three leading capitals + ("Abcdef", True), + ("ABCDef", False), # four leading capitals + ("aBcdef", False), # capital not leading + ("ABcDef", False), # three capitals but not all leading + ("ABCDEF", True), # all caps is not "mixed case" + ("abc_", False), # trailing punctuation + ("a.b.c", True), # two punctuation characters, non-consecutive + ("12abc", True), # leading digit group only + ("abc12", True), +]) +async def test_strict_rules(ircd_hub, strict, username, ok): + nick = "ste" + username.lower().replace(".", "").replace("_", "")[:6] + got = await _try_register(ircd_hub["host"], ircd_hub["port"], nick, username) + assert got == ok, f"{username!r}: expected {'accept' if ok else 'reject'}" + + +@pytest.mark.parametrize("username", ["a__b", "a-_b", "a.b.c.d", "-abc", "1234"]) +async def test_always_rejected_even_when_lenient(ircd_hub, username): + assert not await _try_register(ircd_hub["host"], ircd_hub["port"], "stalw", username) From 209c1fe1987e3f5abc0fa4b751fb817705627601 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:34:32 +0200 Subject: [PATCH 2/9] Add the missing "from" keyword to the configuration lexer The grammar accepts "Include from "file";" to restrict which block types an included file may contain, but the hand-coded lexer (844238f) never emitted the FROM token, so that form was always a syntax error. Add the keyword to the token table. The restricted-include tests in tests/config/ now pass; a new test checks that a forbidden block type is reported. --- ircd/ircd_lexer.c | 1 + tests/config/test_include.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ircd/ircd_lexer.c b/ircd/ircd_lexer.c index d99c6dac..cb00acea 100644 --- a/ircd/ircd_lexer.c +++ b/ircd/ircd_lexer.c @@ -95,6 +95,7 @@ static const struct lexer_token tokens[] = { { "fingerprint", FINGERPRINT }, { "force_local_opmode", TPRIV_FORCE_LOCAL_OPMODE }, { "force_opmode", TPRIV_FORCE_OPMODE }, + { "from", FROM }, { "gb", GBYTES }, { "gbytes", GBYTES }, { "general", GENERAL }, diff --git a/tests/config/test_include.py b/tests/config/test_include.py index c91ef6fb..450d712b 100644 --- a/tests/config/test_include.py +++ b/tests/config/test_include.py @@ -109,10 +109,6 @@ async def test_missing_include_file_exits_promptly(ircd_hub): assert result.returncode != 137, "ircd hung and was killed by timeout" -@pytest.mark.xfail( - reason="the hand-coded lexer has no FROM token, so 'Include from \"file\"' is a syntax error", - strict=True, -) async def test_include_restricted_to_block_types(ircd_hub): files = { "types_main.conf": BASE % {"extra": 'Include Client from "types_extra.conf";'}, @@ -175,3 +171,14 @@ async def test_include_relative_to_dpath(ircd_hub): result = _check(ircd_hub["container"], "rel_main.conf", files, client="probe@10.55.0.1") assert _ok(result), result assert "Match!" in result.stdout + result.stderr + + +async def test_include_restricted_block_type_refused(ircd_hub): + """A block outside the listed types in "Include from" is an error.""" + files = { + "types2_main.conf": BASE % {"extra": 'Client { ip = "*"; class = "Local"; };\nInclude Class from "types2_extra.conf";'}, + "types2_extra.conf": 'Client { ip = "*"; class = "Local"; };\n', + } + result = _check(ircd_hub["container"], "types2_main.conf", files) + assert result.returncode != 0, result + assert "forbidden" in result.stderr, result.stderr From 8575b051c7054552723f14056bad914a449cc15d Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:36:22 +0200 Subject: [PATCH 3/9] Do not hang when an included configuration file cannot be opened lexer_open() keeps a lex_file whose fd is -1 when an Include target cannot be opened, and yylex() returned TOKERR for it on every call. The parser's error recovery never found a token it could resync on, so "ircd -k" (and a REHASH) spun forever on a missing include file. Treat an unopenable file like an empty one: pop it and return TEOF (or end of input if it was the main file) so parsing ends normally with the "error opening file" diagnostic already reported. --- ircd/ircd_lexer.c | 10 ++++++++-- tests/config/test_include.py | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/ircd/ircd_lexer.c b/ircd/ircd_lexer.c index cb00acea..d0baa32f 100644 --- a/ircd/ircd_lexer.c +++ b/ircd/ircd_lexer.c @@ -316,8 +316,14 @@ int yylex(void) if (!yy_in) return YYEOF; - if (yy_in->fd < 0) - return TOKERR; + if (yy_in->fd < 0) { + /* The file could not be opened (lexer_open() already reported it). + * Treat it as an empty file: pop it and end the include, instead of + * returning TOKERR forever and hanging the parser's error recovery. + */ + lexer_pop(); + return yy_in ? TEOF : YYEOF; + } for (;;) { pos = yy_in->buf + yy_in->tok_ofs; diff --git a/tests/config/test_include.py b/tests/config/test_include.py index 450d712b..0ba146fd 100644 --- a/tests/config/test_include.py +++ b/tests/config/test_include.py @@ -99,14 +99,21 @@ async def test_missing_include_file_is_reported(ircd_hub): assert "error opening file" in result.stderr, result.stderr -@pytest.mark.xfail( - reason="ircd never exits after failing to open an Include file (killed by timeout)", - strict=True, -) async def test_missing_include_file_exits_promptly(ircd_hub): files = {"miss2_main.conf": BASE % {"extra": 'Include "does_not_exist.conf";'}} result = _check(ircd_hub["container"], "miss2_main.conf", files) - assert result.returncode != 137, "ircd hung and was killed by timeout" + assert result.returncode == 7, f"expected a configuration error exit, got {result}" + assert "error opening file" in result.stderr, result.stderr + + +async def test_missing_include_in_the_middle_still_parses_rest(ircd_hub): + """The blocks after a missing include are still parsed (and the check fails).""" + files = { + "miss3_main.conf": BASE % {"extra": 'Include "does_not_exist.conf";\nClient { ip = "*"; class = "Local"; };'}, + } + result = _check(ircd_hub["container"], "miss3_main.conf", files, client="probe@10.55.0.1") + assert result.returncode == 7, result + assert "error opening file" in result.stderr, result.stderr async def test_include_restricted_to_block_types(ircd_hub): From 9b2c888e0120570d65897940b61f0e2d9af66f29 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:37:13 +0200 Subject: [PATCH 4/9] Refuse recursive and excessively nested Include directives An included file that includes itself (directly or through another file) recursed until the parser stack overflowed ("memory exhausted"), after which deinit_lexer()'s assert(!yy_in) aborted the daemon -- for a REHASH that means the running server dies. lexer_include() now walks the input stack: a file that is already being read is reported as "recursive include", and nesting deeper than MAX_INCLUDE_DEPTH (16) as "include nesting too deep". In both cases an input that yields no tokens is pushed so the Include block still ends with TEOF and parsing continues. deinit_lexer() unwinds whatever is left on the stack instead of asserting. --- ircd/ircd_lexer.c | 47 +++++++++++++++++++++++++++++++++--- tests/config/test_include.py | 38 +++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/ircd/ircd_lexer.c b/ircd/ircd_lexer.c index d0baa32f..e5c306db 100644 --- a/ircd/ircd_lexer.c +++ b/ircd/ircd_lexer.c @@ -210,23 +210,42 @@ const char *lexer_position(int *lineno) return ""; } -static int lexer_open(const char *fname, int allow_fail, unsigned int allowed) +/** Maximum nesting depth for Include directives. */ +#define MAX_INCLUDE_DEPTH 16 + +/** Push a new input file onto the lexer's stack. + * @param[in] fname Name to report for the file. + * @param[in] fd Open file descriptor, or -1 for a file that yields no + * tokens (yylex() pops it and returns TEOF). + * @param[in] allowed Bitmask of block types permitted in the file. + * @return The new lexer input. + */ +static struct lex_file *lexer_push(const char *fname, int fd, unsigned int allowed) { struct lex_file *obj; obj = MyMalloc(sizeof(*obj)); - obj->fd = open(fname, O_RDONLY | O_NOCTTY | O_CLOEXEC); + obj->fd = fd; DupString(obj->name, fname); obj->allowed = allowed; obj->parent = yy_in; obj->lineno = 1; obj->tok_ofs = obj->buf_used = 0; yy_in = obj; + return obj; +} + +static int lexer_open(const char *fname, int allow_fail, unsigned int allowed) +{ + struct lex_file *obj; + + obj = lexer_push(fname, open(fname, O_RDONLY | O_NOCTTY | O_CLOEXEC), allowed); if (obj->fd < 0) { yyerror("error opening file"); if (!allow_fail) { yy_in = obj->parent; + MyFree(obj->name); MyFree(obj); return -1; } @@ -275,8 +294,8 @@ int init_lexer(void) void deinit_lexer(void) { - assert(!yy_in); - + /* A parse that was abandoned (e.g. by a parser stack overflow) leaves + * inputs on the stack; unwind them instead of asserting. */ while (yy_in) { lexer_pop(); } @@ -284,6 +303,26 @@ void deinit_lexer(void) void lexer_include(const char *fname, unsigned int allowed) { + struct lex_file *obj; + unsigned int depth = 0; + + /* Refuse recursive includes and unreasonable nesting; either would + * otherwise recurse until the parser stack overflows. Push an input + * that yields no tokens so the Include block still ends with TEOF. */ + for (obj = yy_in; obj; obj = obj->parent) { + ++depth; + if (0 == strcmp(obj->name, fname)) { + lexer_push(fname, -1, allowed); + yyerror("recursive include"); + return; + } + } + if (depth >= MAX_INCLUDE_DEPTH) { + lexer_push(fname, -1, allowed); + yyerror("include nesting too deep"); + return; + } + lexer_open(fname, 1, allowed); } diff --git a/tests/config/test_include.py b/tests/config/test_include.py index 0ba146fd..ec977a15 100644 --- a/tests/config/test_include.py +++ b/tests/config/test_include.py @@ -151,20 +151,42 @@ async def test_include_of_empty_and_comment_only_file(ircd_hub): assert _ok(result), result -@pytest.mark.xfail( - reason="a self-including file aborts ircd (SIGABRT after 'memory exhausted' from the parser)", - strict=True, -) async def test_include_cycle_is_not_fatal(ircd_hub): - """A file that includes itself must not make ircd loop or crash.""" + """A file that includes itself is reported, not recursed into.""" files = { "cycle_main.conf": BASE % {"extra": 'Client { ip = "*"; class = "Local"; };\nInclude "cycle_self.conf";'}, "cycle_self.conf": 'Include "cycle_self.conf";\n', } result = _check(ircd_hub["container"], "cycle_main.conf", files) - assert result.returncode != 137, "ircd hung on a self-including file" - assert result.returncode != 134, f"ircd aborted on a self-including file: {result.stderr[-200:]}" - assert result.returncode != 0 + assert result.returncode == 7, result + assert "recursive include" in result.stderr, result.stderr + + +async def test_mutual_include_cycle_is_not_fatal(ircd_hub): + files = { + "mcyc_main.conf": BASE % {"extra": 'Include "mcyc_a.conf";'}, + "mcyc_a.conf": 'Include "mcyc_b.conf";\n', + "mcyc_b.conf": 'Client { ip = "*"; class = "Local"; };\nInclude "mcyc_a.conf";\n', + } + result = _check(ircd_hub["container"], "mcyc_main.conf", files) + assert result.returncode == 7, result + assert "recursive include" in result.stderr, result.stderr + + +async def test_include_nesting_limit(ircd_hub): + """More than 16 nested includes is refused; a chain below the limit is fine.""" + files = {"deep_main.conf": BASE % {"extra": 'Include "deep1.conf";'}} + for i in range(1, 21): + files[f"deep{i}.conf"] = f'Include "deep{i + 1}.conf";\n' + files["deep21.conf"] = 'Client { ip = "*"; class = "Local"; };\n' + result = _check(ircd_hub["container"], "deep_main.conf", files) + assert result.returncode == 7, result + assert "include nesting too deep" in result.stderr, result.stderr + + files = {"deep_ok.conf": BASE % {"extra": 'Include "deep17.conf";'}} + result = _check(ircd_hub["container"], "deep_ok.conf", files, client="probe@10.55.0.1") + assert _ok(result), result + assert "Match!" in result.stdout + result.stderr async def test_include_relative_to_dpath(ircd_hub): From 5ca7fe550eb58d8c9f6638b62bad4878e79e0385 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:37:57 +0200 Subject: [PATCH 5/9] Allow an included configuration file to be empty The Include rule required at least one block in the included file ("blocks TEOF"), so a file containing only comments -- or one that could not be opened -- produced a spurious "syntax error" after the real diagnostic. Accept an empty include body. --- ircd/ircd_parser.y | 5 ++++- tests/config/test_include.py | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ircd/ircd_parser.y b/ircd/ircd_parser.y index 1c5491ef..27f02138 100644 --- a/ircd/ircd_parser.y +++ b/ircd/ircd_parser.y @@ -1492,7 +1492,10 @@ includeblock: INCLUDE { } blockspec ';' { lexer_include($3, flags); yychar = YYEMPTY; -} blocks TEOF; +} includebody TEOF; + +/* An included file may legitimately be empty or contain only comments. */ +includebody: /* empty */ | blocks; blockspec: QSTRING { flags = ~0; } | blocktypes FROM QSTRING { flags = $1; $$ = $3; }; diff --git a/tests/config/test_include.py b/tests/config/test_include.py index ec977a15..cb361cdd 100644 --- a/tests/config/test_include.py +++ b/tests/config/test_include.py @@ -114,6 +114,7 @@ async def test_missing_include_in_the_middle_still_parses_rest(ircd_hub): result = _check(ircd_hub["container"], "miss3_main.conf", files, client="probe@10.55.0.1") assert result.returncode == 7, result assert "error opening file" in result.stderr, result.stderr + assert "syntax error" not in result.stderr, result.stderr async def test_include_restricted_to_block_types(ircd_hub): @@ -138,10 +139,6 @@ async def test_hash_comments_and_quoted_strings(ircd_hub): assert _ok(result), result -@pytest.mark.xfail( - reason="the grammar requires at least one block per included file, so an empty/comment-only include is a syntax error", - strict=True, -) async def test_include_of_empty_and_comment_only_file(ircd_hub): files = { "empty_main.conf": BASE % {"extra": 'Client { ip = "*"; class = "Local"; };\nInclude "empty_extra.conf";'}, From cd73030b31bc897f14b8e981c7c9b32e272c8967 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:37:58 +0200 Subject: [PATCH 6/9] mo_info: stop the public INFO text at the "Sources:" marker m_info() and ms_info() were changed to end the public text at the "Sources:" line instead of a hard-coded offset, but mo_info() still skipped text[218] entries. Since the number of hashed source files has grown, an operator asking "INFO " silently lost the first part of the hash list (IPcheck.c ... channel.c never appeared) and an operator without a server argument got no public text at all. Use the same marker logic in mo_info(): everybody gets the public text, and operators who name a server get the complete hash list after it. --- ircd/m_info.c | 13 ++++++------- tests/commands/test_info.py | 14 +++++++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/ircd/m_info.c b/ircd/m_info.c index 3ed46cf3..68f080c0 100644 --- a/ircd/m_info.c +++ b/ircd/m_info.c @@ -175,13 +175,12 @@ int mo_info(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) if (hunt_server_cmd(sptr, CMD_INFO, cptr, 1, ":%C", 1, parc, parv) == HUNTED_ISME) { - while (text[218]) - { - if (!IsOper(sptr)) - send_reply(sptr, RPL_INFO, *text); - text++; - } - if (IsOper(sptr) && (NULL != parv[1])) + /* The public text ends at the "Sources:" marker (as in m_info()); + * the file hash list that follows is only shown to operators who + * asked for a specific server. */ + while (*text && strcmp(*text, "Sources:")) + send_reply(sptr, RPL_INFO, *text++); + if (NULL != parv[1]) { while (*text) send_reply(sptr, RPL_INFO, *text++); diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index 1964893b..e2c7baa5 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -55,11 +55,6 @@ async def test_oper_sees_hashes(oper): assert any("client.h" in l for l in hashes) -@pytest.mark.xfail( - reason="mo_info() still skips text[218] entries (m_info/ms_info were fixed to stop at " - "'Sources:'), so opers lose the first source hashes as the file count grows", - strict=True, -) async def test_oper_sees_every_source_hash(oper): lines = await _info(oper, "hub.test.net") hashes = _hashes(lines) @@ -72,9 +67,18 @@ async def test_oper_without_server_argument_sees_no_hashes(oper): """mo_info only sends the hash section when a server name is given.""" lines = await _info(oper) assert not _hashes(lines), _hashes(lines)[:3] + assert lines and lines[0] == "IRC --", lines[:3] + assert "Sources:" not in lines assert any(l.startswith("Birth Date:") for l in lines) +async def test_oper_and_user_see_the_same_public_text(oper, make_client): + plain = await make_client("info5") + plain_lines = [l for l in await _info(plain) if not l.startswith(FOOTER)] + oper_lines = [l for l in await _info(oper) if not l.startswith(FOOTER)] + assert plain_lines == oper_lines + + async def test_oper_hash_lines_are_well_formed(oper): """Each hash line names a source file and a 32-hex MD5 (umkpasswd -5).""" lines = await _info(oper, "hub.test.net") From d62e82bbaae3434692cc8c5274c34dd2da5756cc Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 07:37:58 +0200 Subject: [PATCH 7/9] Echo CPRIVMSG and CNOTICE to senders with echo-message whisper() delivered the message to the target but, unlike the PRIVMSG/NOTICE relay paths (and WALLCHOPS/WALLVOICES), never sent the echo-message copy back to the sender. Add it after a successful delivery so clients with the capability see their own CPRIVMSG and CNOTICE traffic like every other message. --- ircd/s_user.c | 11 ++++++ tests/cap/test_cap_edge_cases_main.py | 48 +++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/ircd/s_user.c b/ircd/s_user.c index bcce0032..c29a9b06 100644 --- a/ircd/s_user.c +++ b/ircd/s_user.c @@ -820,6 +820,17 @@ int whisper(struct Client* source, const char* nick, const char* channel, send_reply(source, RPL_AWAY, cli_name(dest), cli_user(dest)->away); sendcmdto_one(source, CMD_PRIVATE, dest, "%C :%s", dest, text); } + + /* echo-message: hand the sender a copy, as PRIVMSG/NOTICE do. + * (CMD_* expand to a message/token pair, hence the two calls.) */ + if (CapHas(cli_active(source), CAP_ECHOMESSAGE)) + { + if (is_notice) + sendcmdto_one(source, CMD_NOTICE, cli_from(source), "%C :%s", dest, text); + else + sendcmdto_one(source, CMD_PRIVATE, cli_from(source), "%C :%s", dest, text); + } + return 0; } diff --git a/tests/cap/test_cap_edge_cases_main.py b/tests/cap/test_cap_edge_cases_main.py index 1d0bf7b5..622b539c 100644 --- a/tests/cap/test_cap_edge_cases_main.py +++ b/tests/cap/test_cap_edge_cases_main.py @@ -126,10 +126,6 @@ async def test_echo_message_multi_target(make_client): assert got.params[-1] == "both of you" -@pytest.mark.xfail( - reason="CPRIVMSG/CNOTICE (whisper) never echo the message, unlike PRIVMSG/NOTICE", - strict=True, -) async def test_echo_message_cprivmsg(make_client): chan = "#ech_cprivmsg" op = await make_client("ech_op5", caps=["echo-message"]) @@ -139,6 +135,46 @@ async def test_echo_message_cprivmsg(make_client): await asyncio.sleep(0.2) await drain(op) await op.send(f"CPRIVMSG ech_peer5 {chan} :whispered") + got = await peer.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert got.params == ["ech_peer5", "whispered"], got.raw + echo = await op.wait_for_user_msg("PRIVMSG", timeout=5.0) + assert sender_nick(echo) == "ech_op5" and echo.params == ["ech_peer5", "whispered"], echo.raw + + +async def test_echo_message_cnotice(make_client): + chan = "#ech_cnotice" + op = await make_client("ech_op6", caps=["echo-message"]) + peer = await make_client("ech_peer6") + await join(op, chan) + await join(peer, chan) + await asyncio.sleep(0.2) + await drain(op) + await op.send(f"CNOTICE ech_peer6 {chan} :whispered notice") + got = await peer.wait_for_user_msg("NOTICE", timeout=5.0) + assert got.params == ["ech_peer6", "whispered notice"], got.raw + echo = await op.wait_for_user_msg("NOTICE", timeout=5.0) + assert echo.params == ["ech_peer6", "whispered notice"], echo.raw + + +async def test_no_cprivmsg_echo_without_cap(make_client): + chan = "#ech_cprivmsg_nocap" + op = await make_client("ech_op7") + peer = await make_client("ech_peer7") + await join(op, chan) + await join(peer, chan) + await asyncio.sleep(0.2) + await drain(op) + await op.send(f"CPRIVMSG ech_peer7 {chan} :quiet") await peer.wait_for_user_msg("PRIVMSG", timeout=5.0) - echo = await op.wait_for_user_msg("PRIVMSG", timeout=3.0) - assert echo.params == ["ech_peer5", "whispered"] + await op.assert_no_message("PRIVMSG", timeout=1.0) + + +async def test_refused_cprivmsg_is_not_echoed(make_client): + """A whisper the server rejects (target not on channel) has no echo.""" + chan = "#ech_cprivmsg_refused" + op = await make_client("ech_op8", caps=["echo-message"]) + other = await make_client("ech_other8") + await join(op, chan) + await op.send(f"CPRIVMSG ech_other8 {chan} :nope") + await op.wait_for("441", timeout=5.0) + await op.assert_no_message("PRIVMSG", timeout=1.0) From 045198e253c27eb9b8da5a70d9738fd1cab6a3ff Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 09:37:37 +0200 Subject: [PATCH 8/9] Do not send ERR_TARGETTOOFAST or penalise joins that JOIN_TARGET allows Since 5ffe0a1, JOIN_TARGET=FALSE (the default) lets a client join channels beyond its free-target budget and defers the target charge to the first message on the channel. check_target_join() still called check_target_limit(), which had already sent ERR_TARGETTOOFAST and added two seconds to cli_nexttarget before returning: every allowed join past the budget produced a spurious "Target change too fast" reply immediately followed by the successful JOIN, and a 25-channel burst left the client with 30 seconds of extra target penalty. Split check_target_limit() into a static core with a "report" flag and add check_target_limit_quiet(), which returns the verdict without sending the numeric or applying the penalty; use it when JOIN_TARGET is off. The JOIN_TARGET=TRUE path is unchanged. Document JOIN_TARGET in readme.features, which never listed it. --- doc/readme.features | 11 +++++++++ include/s_user.h | 2 ++ ircd/m_join.c | 14 +++++++----- ircd/s_user.c | 40 ++++++++++++++++++++++++++++----- tests/relay/test_join_target.py | 12 ++++++---- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/doc/readme.features b/doc/readme.features index c7512ed8..d365ae44 100644 --- a/doc/readme.features +++ b/doc/readme.features @@ -967,6 +967,17 @@ AWAY_BURST Send the away message for clients flagged as away during burst. +JOIN_TARGET + * Type: boolean + * Default: FALSE + +Whether the target-change limit (ERR_TARGETTOOFAST) applies to JOIN. +When FALSE, a user may always join channels: a join beyond the free +target budget is not refused and not reported, the channel is simply +charged as a target when the user first speaks on (or parts) it. When +TRUE, joins beyond the budget are refused with ERR_TARGETTOOFAST like +messages to new targets. + CHANNELLEN * Type: integer * Default: 200 diff --git a/include/s_user.h b/include/s_user.h index 4c4d63c1..e23efd34 100644 --- a/include/s_user.h +++ b/include/s_user.h @@ -100,6 +100,8 @@ extern void set_snomask(struct Client *, unsigned int, int); extern int is_snomask(char *); extern int check_target_limit(struct Client *sptr, struct Client *acptr, struct Channel *chptr); +extern int check_target_limit_quiet(struct Client *sptr, struct Client *acptr, + struct Channel *chptr); extern void add_target(struct Client *sptr, void *target); extern unsigned int umode_make_snomask(unsigned int oldmask, char *arg, int what); diff --git a/ircd/m_join.c b/ircd/m_join.c index 7a925e63..65b73c72 100644 --- a/ircd/m_join.c +++ b/ircd/m_join.c @@ -104,12 +104,14 @@ last0(struct Client *cptr, struct Client *sptr, char *chanlist) */ static int check_target_join(struct Client *cptr, struct Channel *chptr) { - if (check_target_limit(cptr, NULL, chptr)) - { - return feature_bool(FEAT_JOIN_TARGET) ? 1 : CHFL_DELAYED_TARGET; - } - - return 0; + if (feature_bool(FEAT_JOIN_TARGET)) + return check_target_limit(cptr, NULL, chptr) ? 1 : 0; + + /* The join is allowed regardless: only find out whether the target + * budget covered it, without sending ERR_TARGETTOOFAST or applying + * the penalty. If not, the target is charged when the user first + * speaks on (or parts) the channel instead. */ + return check_target_limit_quiet(cptr, NULL, chptr) ? CHFL_DELAYED_TARGET : 0; } /** Handle a JOIN message from a client connection. diff --git a/ircd/s_user.c b/ircd/s_user.c index c29a9b06..5c349ffc 100644 --- a/ircd/s_user.c +++ b/ircd/s_user.c @@ -693,11 +693,13 @@ add_target(struct Client *sptr, void *target) * @param[in] sptr User trying to join a channel or send a message. * @param[in] acptr Destination client (NULL if sending to a channel). * @param[in] chptr Destination channel (NULL if sending to a client). - * @return Non-zero if too many target changes (after sending - * ERR_TARGETTOOFAST); zero if okay to send. + * @param[in] report If non-zero, send ERR_TARGETTOOFAST and apply the + * anti-flood penalty when the limit is hit; if zero, only report the + * verdict (for callers that proceed regardless). + * @return Non-zero if too many target changes; zero if okay to send. */ -int check_target_limit(struct Client *sptr, struct Client *acptr, - struct Channel *chptr) +static int check_target_limit_int(struct Client *sptr, struct Client *acptr, + struct Channel *chptr, int report) { unsigned char hash = hash_target(acptr ? (void *)acptr : chptr); int i; @@ -727,7 +729,7 @@ int check_target_limit(struct Client *sptr, struct Client *acptr, /* If user is invited to channel, give him/her a free target */ if (chptr && IsInvited(sptr, chptr)) return 0; - if (cli_nexttarget(sptr) - CurrentTime < TARGET_DELAY + 8) { + if (report && cli_nexttarget(sptr) - CurrentTime < TARGET_DELAY + 8) { const char *name; /* * No server flooding @@ -750,6 +752,34 @@ int check_target_limit(struct Client *sptr, struct Client *acptr, return 0; } +/** Check whether \a sptr can send to or join \a target yet, sending + * ERR_TARGETTOOFAST (and applying the anti-flood penalty) if not. + * @param[in] sptr User trying to join a channel or send a message. + * @param[in] acptr Destination client (NULL if sending to a channel). + * @param[in] chptr Destination channel (NULL if sending to a client). + * @return Non-zero if too many target changes (after sending + * ERR_TARGETTOOFAST); zero if okay to send. + */ +int check_target_limit(struct Client *sptr, struct Client *acptr, + struct Channel *chptr) +{ + return check_target_limit_int(sptr, acptr, chptr, 1); +} + +/** Like check_target_limit(), but silent: no ERR_TARGETTOOFAST and no + * penalty when the limit is hit. For callers that proceed either way + * and only need to know whether the target was charged now. + * @param[in] sptr User trying to join a channel or send a message. + * @param[in] acptr Destination client (NULL if sending to a channel). + * @param[in] chptr Destination channel (NULL if sending to a client). + * @return Non-zero if too many target changes; zero if okay to send. + */ +int check_target_limit_quiet(struct Client *sptr, struct Client *acptr, + struct Channel *chptr) +{ + return check_target_limit_int(sptr, acptr, chptr, 0); +} + /** Allows a channel operator to avoid target change checks when * sending messages to users on their channel. * @param[in] source User sending the message. diff --git a/tests/relay/test_join_target.py b/tests/relay/test_join_target.py index e3de887c..1e21a922 100644 --- a/tests/relay/test_join_target.py +++ b/tests/relay/test_join_target.py @@ -1,9 +1,11 @@ """Joining many channels at once (commits 5ffe0a1, 54cfd56). With FEAT_JOIN_TARGET=FALSE (default) a JOIN never fails because of the -target-change limit: the membership is flagged "delayed target" instead. -With FEAT_JOIN_TARGET=TRUE the historical behaviour applies and joins -beyond the free-target budget are refused with ERR_TARGETTOOFAST. +target-change limit: the membership is flagged "delayed target" instead, +and -- since the join is allowed -- no ERR_TARGETTOOFAST is sent and no +penalty is applied. With FEAT_JOIN_TARGET=TRUE the historical behaviour +applies and joins beyond the free-target budget are refused with +ERR_TARGETTOOFAST. """ from __future__ import annotations @@ -36,11 +38,13 @@ async def _burst_join(client, prefix): async def test_join_burst_not_limited_by_default(make_client): client = await make_client("jtburst1") - chans, joined, _ = await _burst_join(client, "jt_free") + chans, joined, tfast = await _burst_join(client, "jt_free") assert joined == {c.lower() for c in chans}, ( f"only {len(joined)}/{CHANNELS} channels joined: missing " f"{sorted(set(c.lower() for c in chans) - joined)}" ) + # An allowed join must not be reported as "too fast". + assert tfast == 0, f"got {tfast} ERR_TARGETTOOFAST for joins that succeeded" async def test_join_burst_limited_with_JOIN_TARGET(make_client, oper): From 50a05e55506367b2b251bd74b71b0dadd595dc6b Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 30 Aug 2026 11:39:59 +0200 Subject: [PATCH 9/9] tests: give the oper fixture a unique nick per test The fixture always registered as "testop". ircu defers a client's commands once its flood penalty builds up, so the previous test's QUIT could still be pending when the next test registered, which failed with ERR_NICKNAMEINUSE (seen once in a full-suite run). --- tests/conftest.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 72ccb018..b9187d28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """pytest fixtures for ircu2 integration testing.""" +import itertools import os import subprocess import time @@ -660,12 +661,20 @@ async def _make( await client.disconnect() +_oper_seq = itertools.count(1) + + @pytest_asyncio.fixture async def oper(make_client): - """A registered global operator on the hub.""" + """A registered global operator on the hub. + + The nick is unique per test: ircu defers a client's commands once its + flood penalty builds up, so the previous test's QUIT may still be + pending when the next test registers. + """ from cap_helpers import oper_up - client = await make_client("testop") + client = await make_client(f"op{next(_oper_seq)}") await oper_up(client) return client