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:
co-authored by
Claude Opus 5.5
parent
dc1df72c86
commit
455c55f5c2
@@ -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"
|
||||
Reference in New Issue
Block a user