feat(discord): self-serve server->org linking with /drb link (#213 item 1)

- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
  10-minute code; minting revokes the org's older codes. The bot redeems it
  via POST /discord/guilds/{id}/link/redeem (service key) inside one
  Firestore transaction, so two servers racing for one code cannot both win
  and a server linked to org A refuses org B's code. GET/DELETE
  /discord/linked-guilds lists/unlinks the caller's org's servers; unlink
  stops that server's streams. Rate-limited (mint per user, redeem per
  server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
  the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
  GatedTree answers every other command in an unlinked server with how to
  link (autocomplete returns nothing). With the default fallback org set,
  nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
  generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
  revoked/malformed codes, wrong-org takeover, idempotent relink, rate
  limits, fallback stop, scoped list, unlink without fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 20:01:25 -04:00
co-authored by Claude Opus 5.5
parent dc1df72c86
commit 455c55f5c2
14 changed files with 960 additions and 3 deletions
@@ -0,0 +1,251 @@
"""
Self-serve Discord server -> org linking (server-26#213 item 1).
Race tests run two real transactions against tests/fake_firestore.py, held
together at the commit point, so the interleaving the single-use guarantee
has to survive actually happens.
"""
import asyncio
import threading
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import auth as auth_mod
from app.internal import discord_guild_link as glink
from app.internal import discord_streams as streams
from app.routers import discord_link as router
from tests.fake_firestore import FakeFirestore
OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
fake.put("organizations", "org-a", {"org_id": "org-a", "name": "Riverton Fire Co. 7"})
fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Harbor Towing"})
for lim in (router.mint_limiter, router.redeem_guild_limiter, router.redeem_user_limiter):
lim._log.clear()
stopped: list = []
async def _stop_guild(guild_id, reason, channel_id=None):
stopped.append((guild_id, reason))
return [f"{guild_id}:c1"]
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
fake.stopped = stopped
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
return fake
def _audit(fake, action):
return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
async def _mint(decoded=OWNER_A):
return await router.create_link_code(org_id=None, decoded=decoded)
async def _redeem(code, guild="g1", user="u1", name="Station 7"):
return await router.redeem_link_code(
guild, router.RedeemBody(code=code, discord_user_id=user, guild_name=name), principal={"service": True})
def _status(resp):
return getattr(resp, "status_code", 200)
def _hold_both_at_commit(fake):
barrier = threading.Barrier(2, timeout=5)
def _hook(attempt):
if attempt == 0:
barrier.wait()
fake.before_commit = _hook
# ---------------------------------------------------------------------------
# Mint
# ---------------------------------------------------------------------------
async def test_mint_returns_display_code_and_audits(fs):
out = await _mint()
assert len(out["code"]) == 12 and out["code"][3] == "-" and out["code"][8] == "-"
assert out["expires_minutes"] == glink.CODE_TTL_MINUTES
assert out["command"] == f"/drb link code:{out['code']}"
doc = fs.get(glink.CODES, glink.normalize(out["code"]))
assert doc["org_id"] == "org-a" and doc["redeemed_at"] is None and doc["revoked"] is False
assert len(_audit(fs, "discord.link_code.create")) == 1
async def test_minting_again_revokes_the_previous_code(fs):
first = await _mint()
second = await _mint()
assert fs.get(glink.CODES, glink.normalize(first["code"]))["revoked"] is True
resp = await _redeem(first["code"])
assert _status(resp) == 404
assert _status(await _redeem(second["code"])) == 200
async def test_mint_rate_limited_per_user(fs):
for _ in range(5):
await _mint()
with pytest.raises(HTTPException) as e:
await _mint()
assert e.value.status_code == 429
async def test_member_cannot_mint(fs, monkeypatch):
member = {**OWNER_A, "org_role": "member"}
monkeypatch.setattr(auth_mod.firebase_auth, "verify_id_token", lambda _t: member)
class _Cred:
credentials = "tok"
with pytest.raises(HTTPException) as e:
await auth_mod.require_org_owner_token(_Cred())
assert e.value.status_code == 403
# ---------------------------------------------------------------------------
# Redeem
# ---------------------------------------------------------------------------
async def test_redeem_links_the_guild_and_is_single_use(fs):
out = await _mint()
resp = await _redeem(out["code"].lower()) # pasted in lower case, dashes kept
assert resp["org_id"] == "org-a" and resp["org_name"] == "Riverton Fire Co. 7"
assert await streams.resolve_guild_org("g1") == "org-a"
g = fs.get(streams.GUILDS, "g1")
assert g["linked_by"] == "discord:u1" and g["linked_via"] == "code" and g["guild_name"] == "Station 7"
code_doc = fs.get(glink.CODES, glink.normalize(out["code"]))
assert code_doc["redeemed_guild_id"] == "g1"
assert len(_audit(fs, "discord.guild.link")) == 1
again = await _redeem(out["code"], guild="g2", user="u2")
assert _status(again) == 409
assert fs.get(streams.GUILDS, "g2") is None
assert [e["details"]["reason"] for e in _audit(fs, "discord.guild.link_refused")] == ["code_used"]
async def test_two_servers_racing_for_one_code_only_one_wins(fs):
out = await _mint()
_hold_both_at_commit(fs)
r1, r2 = await asyncio.gather(_redeem(out["code"], guild="g1", user="u1"),
_redeem(out["code"], guild="g2", user="u2"))
assert fs.retries >= 1 # the race really happened
assert sorted([_status(r1), _status(r2)]) == [200, 409]
linked = [g for g in ("g1", "g2") if (fs.get(streams.GUILDS, g) or {}).get("org_id")]
assert len(linked) == 1
assert fs.get(glink.CODES, glink.normalize(out["code"]))["redeemed_guild_id"] == linked[0]
async def test_one_server_racing_two_orgs_codes_ends_on_exactly_one(fs):
a = await _mint(OWNER_A)
b = await _mint(OWNER_B)
_hold_both_at_commit(fs)
r1, r2 = await asyncio.gather(_redeem(a["code"], user="u1"), _redeem(b["code"], user="u2"))
assert sorted([_status(r1), _status(r2)]) == [200, 409]
winner = "org-a" if _status(r1) == 200 else "org-b"
loser_code = b["code"] if winner == "org-a" else a["code"]
assert fs.get(streams.GUILDS, "g1")["org_id"] == winner
# The refused code was not consumed — its org can still use it elsewhere.
assert fs.get(glink.CODES, glink.normalize(loser_code))["redeemed_at"] is None
async def test_expired_code_is_refused(fs):
out = await _mint()
key = glink.normalize(out["code"])
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
fs.put(glink.CODES, key, {**fs.get(glink.CODES, key), "expires_at": past})
resp = await _redeem(out["code"])
assert _status(resp) == 410
assert fs.get(streams.GUILDS, "g1") is None
assert fs.get(glink.CODES, key)["redeemed_at"] is None
async def test_unknown_or_malformed_code_is_refused(fs):
assert _status(await _redeem("NOPE")) == 404
assert _status(await _redeem("ABC-DEFG-HJK")) == 404
async def test_wrong_org_cannot_take_over_a_linked_server(fs):
a = await _mint(OWNER_A)
assert _status(await _redeem(a["code"])) == 200
b = await _mint(OWNER_B)
resp = await _redeem(b["code"], user="u9")
assert _status(resp) == 409
assert fs.get(streams.GUILDS, "g1")["org_id"] == "org-a"
assert fs.get(glink.CODES, glink.normalize(b["code"]))["redeemed_at"] is None
assert fs.stopped == []
async def test_same_org_relink_is_idempotent(fs):
first = await _mint()
await _redeem(first["code"])
linked_at = fs.get(streams.GUILDS, "g1")["linked_at"]
second = await _mint()
resp = await _redeem(second["code"])
assert resp["already_linked"] is True
assert fs.get(streams.GUILDS, "g1")["linked_at"] == linked_at
async def test_redeem_rate_limited_per_server(fs):
for _ in range(5):
await _redeem("WRONGCODE1", user="u-many")
with pytest.raises(HTTPException) as e:
await _redeem("WRONGCODE1", user="u-other")
assert e.value.status_code == 429
# ---------------------------------------------------------------------------
# DISCORD_UNLINKED_GUILD_ORG_ID back-compat
# ---------------------------------------------------------------------------
async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
assert await streams.resolve_guild_org("g1") == "founding"
out = await _mint()
await _redeem(out["code"])
assert fs.stopped == [("g1", "relinked")]
assert await streams.resolve_guild_org("g1") == "org-a"
async def test_linking_to_the_fallback_org_itself_stops_nothing(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "org-a")
out = await _mint()
await _redeem(out["code"])
assert fs.stopped == []
# ---------------------------------------------------------------------------
# List / unlink
# ---------------------------------------------------------------------------
async def test_list_is_scoped_to_the_callers_org(fs, monkeypatch):
await _redeem((await _mint(OWNER_A))["code"], guild="g1")
await _redeem((await _mint(OWNER_B))["code"], guild="g2", user="u2")
out = await router.linked_guilds(org_id="org-b", decoded=OWNER_A) # non-admin override ignored
assert [g["guild_id"] for g in out["guilds"]] == ["g1"]
assert out["unlinked_guilds_restricted"] is True
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
assert (await router.linked_guilds(org_id=None, decoded=OWNER_A))["unlinked_guilds_restricted"] is False
async def test_unlink_stops_streams_and_never_falls_back(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
await _redeem((await _mint())["code"])
fs.stopped.clear()
with pytest.raises(HTTPException) as e:
await router.unlink_guild("g1", org_id=None, decoded=OWNER_B) # not B's server
assert e.value.status_code == 404
out = await router.unlink_guild("g1", org_id=None, decoded=OWNER_A)
assert out["ok"] and fs.stopped == [("g1", "unlinked")]
assert await streams.resolve_guild_org("g1") is None # NOT "founding"
assert len(_audit(fs, "discord.guild.unlink")) == 1
# ...and it can be linked again, by any org, with a fresh code.
resp = await _redeem((await _mint(OWNER_B))["code"])
assert resp["org_id"] == "org-b"
assert await streams.resolve_guild_org("g1") == "org-b"