diff --git a/drb-c2-core/app/internal/discord_streams.py b/drb-c2-core/app/internal/discord_streams.py index f42cfa4..4786a71 100644 --- a/drb-c2-core/app/internal/discord_streams.py +++ b/drb-c2-core/app/internal/discord_streams.py @@ -374,8 +374,15 @@ async def remove_guild(guild_id: str, by: str) -> dict: link = await fstore.doc_get(GUILDS, guild_id) or {} removed = [] for s in await fstore.collection_list(STREAMS, guild_id=guild_id): - if s.get("stream_id") and await delete_stream(s["stream_id"], "server_removed"): - removed.append(s["stream_id"]) + try: + if s.get("stream_id") and await delete_stream(s["stream_id"], "server_removed"): + removed.append(s["stream_id"]) + except StreamError as e: + # A channel restarted mid-removal: stop here, leave the server + # linked, and tell the caller which channels are already gone so + # the partial removal is still audited. + e.removed = removed + raise now = _now_iso() await fstore.doc_set(GUILDS, guild_id, { "guild_id": guild_id, "org_id": None, diff --git a/drb-c2-core/app/routers/discord.py b/drb-c2-core/app/routers/discord.py index a51be4f..5e0e9b6 100644 --- a/drb-c2-core/app/routers/discord.py +++ b/drb-c2-core/app/routers/discord.py @@ -222,6 +222,11 @@ async def remove_guild(guild_id: str, leave_radio_bots: bool = False, try: out = await streams.remove_guild(guild_id, uid) except streams.StreamError as e: + # Channels deleted before the race are gone for good — audit them. + await write_audit(uid, email, "discord.guild.remove", details={ + "guild_id": guild_id, "acting_org_id": acting_org, "outcome": "incomplete", "error": e.code, + "streams_removed": getattr(e, "removed", []), "leave_radio_bots": leave_radio_bots, + }) return _error(e) left: list = [] if leave_radio_bots: diff --git a/drb-c2-core/tests/test_discord_remove_and_logging.py b/drb-c2-core/tests/test_discord_remove_and_logging.py index b4dacdd..b010c0c 100644 --- a/drb-c2-core/tests/test_discord_remove_and_logging.py +++ b/drb-c2-core/tests/test_discord_remove_and_logging.py @@ -278,6 +278,27 @@ async def test_remove_guild_deletes_streams_and_unlinks_without_fallback(fs, mon assert [_lease(fs, t) for t in toks] == [None, None] +def test_remove_guild_interrupted_by_a_restart_is_still_audited(fs, monkeypatch): + import asyncio + a = asyncio.run(_start("1001")) + b = asyncio.run(_start("1002", node_id="n2")) + real_stop = streams.stop_stream + + async def _stop(stream_id, reason="stopped"): + await real_stop(stream_id, reason) + if stream_id == b["stream_id"]: + await _start("1002", node_id="n2") # restarted between stop and delete + monkeypatch.setattr(streams, "stop_stream", _stop) + app.dependency_overrides[require_admin_token] = lambda: ADMIN + + r = client.delete("/discord/guilds/100") + assert r.status_code == 409 and r.json()["code"] == "restarted" + (entry,) = _audit(fs, "discord.guild.remove") + assert entry["details"]["outcome"] == "incomplete" + assert entry["details"]["streams_removed"] == [a["stream_id"]] + assert fs.get("discord_guilds", "100")["org_id"] == ORG # left linked: a live channel remains + + def test_remove_guild_route_hides_server_and_relink_restores_it(fs): import asyncio asyncio.run(_start("1001"))