From ae8de25b27fe163543722fd14806565e339a9843 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 17:55:33 +0100 Subject: [PATCH] fix: verify success completion signal + cross-session verify_url restore _on_verify_success() was missing _nickserv_complete() call, causing _go_ready() to hang at _nickserv_done.wait() when registration completed immediately (no email verification needed). get_pending_registration() was not returning verify_url from the DB, so _resume_pending_verification() never restored self._verify_url -- breaking cross-session captcha resume for OFTC-style networks. Four regression tests added. Co-Authored-By: Claude Opus 4.6 --- src/bouncer/backlog.py | 9 +++--- src/bouncer/network.py | 8 +++-- tests/test_network.py | 69 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/bouncer/backlog.py b/src/bouncer/backlog.py index 9216634..da736f3 100644 --- a/src/bouncer/backlog.py +++ b/src/bouncer/backlog.py @@ -244,20 +244,21 @@ class Backlog: async def get_pending_registration( self, network: str, - ) -> tuple[str, str, str, str] | None: + ) -> tuple[str, str, str, str, str] | None: """Get a pending (unverified) registration for a network. - Returns (nick, password, email, host) or None. + Returns (nick, password, email, host, verify_url) or None. """ assert self._db is not None cursor = await self._db.execute( - "SELECT nick, password, email, host FROM nickserv_creds " + "SELECT nick, password, email, host, verify_url " + "FROM nickserv_creds " "WHERE network = ? AND status = 'pending' " "ORDER BY registered_at DESC LIMIT 1", (network,), ) row = await cursor.fetchone() - return (row[0], row[1], row[2], row[3]) if row else None + return (row[0], row[1], row[2], row[3], row[4]) if row else None async def mark_nickserv_verified(self, network: str, nick: str) -> None: """Promote a pending registration to verified.""" diff --git a/src/bouncer/network.py b/src/bouncer/network.py index a72d162..fd2b34d 100644 --- a/src/bouncer/network.py +++ b/src/bouncer/network.py @@ -955,6 +955,7 @@ class Network: if self.backlog and self._nickserv_password: await self.backlog.mark_nickserv_verified(self.cfg.name, self.nick) self._nickserv_pending = "" + await self._nickserv_complete() async def _resume_pending_verification(self) -> bool: """Check for a pending registration from a previous session and resume. @@ -971,9 +972,9 @@ class Network: if not pending: return False - p_nick, p_pass, p_email, p_host = pending - log.info("[%s] found pending registration: nick=%s email=%s", - self.cfg.name, p_nick, p_email) + p_nick, p_pass, p_email, p_host, p_url = pending + log.info("[%s] found pending registration: nick=%s email=%s url=%s", + self.cfg.name, p_nick, p_email, p_url or "(none)") # If we're already SASL'd as a different nick, we can't verify # for the pending nick on this connection -- just resume email check @@ -981,6 +982,7 @@ class Network: self._nickserv_password = p_pass self._nickserv_email = p_email + self._verify_url = p_url self._nickserv_pending = "verify" self._status(f"resuming verification for {p_nick} ({p_email})") diff --git a/tests/test_network.py b/tests/test_network.py index cec136a..f142255 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1058,6 +1058,35 @@ class TestHandleNickserv: assert net._nickserv_pending == "" bl.mark_nickserv_verified.assert_awaited_once() + @pytest.mark.asyncio + async def test_verify_success_signals_completion(self) -> None: + """_on_verify_success must signal _nickserv_done.""" + bl = _mock_backlog() + net = _net(backlog=bl) + net.nick = "verified_nick" + net._nickserv_pending = "verify" + net._nickserv_password = "pass" + net._nickserv_done = asyncio.Event() + + await net._handle_nickserv("verified_nick has been verified") + assert net._nickserv_pending == "" + assert net._nickserv_done.is_set() + + @pytest.mark.asyncio + async def test_registration_immediate_signals_completion(self) -> None: + """Immediate registration (no email) must signal _nickserv_done.""" + bl = _mock_backlog() + net = _net(backlog=bl) + net.nick = "fastnick" + net._nickserv_pending = "register" + net._nickserv_password = "pass" + net._nickserv_done = asyncio.Event() + + await net._handle_nickserv("Nickname registered under your account") + assert net._nickserv_pending == "" + assert net._nickserv_done.is_set() + bl.mark_nickserv_verified.assert_awaited_once() + @pytest.mark.asyncio async def test_verify_failure(self) -> None: net = _net() @@ -1068,6 +1097,46 @@ class TestHandleNickserv: await net._handle_nickserv("Invalid verification code") assert net._nickserv_pending == "" + @pytest.mark.asyncio + async def test_resume_pending_restores_verify_url(self) -> None: + """Cross-session resume must restore the verify_url from DB.""" + bl = _mock_backlog( + pending=("oldnick", "pass", "e@mail.tm", "host", "https://oftc/verify/abc"), + ) + net = _net(backlog=bl) + net.state = State.READY + net._running = True + net.nick = "oldnick" + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + + result = await net._resume_pending_verification() + assert result is True + assert net._verify_url == "https://oftc/verify/abc" + assert net._nickserv_email == "e@mail.tm" + assert net._nickserv_password == "pass" + + @pytest.mark.asyncio + async def test_resume_pending_no_url(self) -> None: + """Resume works when verify_url is empty.""" + bl = _mock_backlog( + pending=("nick", "pass", "e@mail.tm", "host", ""), + ) + net = _net(backlog=bl) + net.state = State.READY + net._running = True + net.nick = "nick" + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + + result = await net._resume_pending_verification() + assert result is True + assert net._verify_url == "" + @pytest.mark.asyncio async def test_late_registration_confirmation(self) -> None: """After timeout clears pending state, late confirmation still works."""