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 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 17:55:33 +01:00
parent 0d762ced49
commit ae8de25b27
3 changed files with 79 additions and 7 deletions

View File

@@ -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."""

View File

@@ -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})")

View File

@@ -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."""