Discord REST client, cache and #225 settings (server-26#225 S2)
New app/internal/discord_rest.py: one shared httpx client with the
DiscordBot User-Agent, per-token rate-limit buckets (X-RateLimit-Bucket +
guild), one retry on a 429 of <=3s, global-429 pause per token, and a
process-wide breaker at 4000 401/403/unshared-429 responses per 10 min so
C2 can never get the shared egress IP banned. Tokens are never logged.
inspect_token / refresh_membership / leave_guild / TokenRejected move off
discord_leases onto it. /users/@me/guilds now follows after= pages (it read
one page of 200 and silently dropped the rest); a failed page is unknown,
never a truncated list.
guild_snapshot(token_doc, guild_id): cached, single-flight read of
/guilds/{g}, the bot's own member and /channels for S3's permission check.
401 marks the token invalid; 10004/10007 drop the server from guild_ids;
50001 is confirmed with one membership read and never changes guild_ids
on its own. TTLs: ok 120s, not-member/invalid 600s, unknown 30s.
config.py gets every setting the #225 slices use (perm-check kill switch,
TTLs, breaker budget, OAuth redirect URI, discord_pool_bot_guild_cap=90).
No behaviour change beyond pagination, User-Agent and the breaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c4e1f9aa61
commit
2b3b875cdc
@@ -13,6 +13,7 @@ from datetime import datetime, timezone, timedelta
|
||||
import pytest
|
||||
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from tests.fake_firestore import FakeFirestore
|
||||
|
||||
@@ -38,7 +39,7 @@ def fs(monkeypatch):
|
||||
|
||||
async def _no_discord(token_ids):
|
||||
return 0
|
||||
monkeypatch.setattr(leases, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(discord_rest, "refresh_membership", _no_discord)
|
||||
return fake
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||
from app.main import app
|
||||
@@ -42,7 +43,7 @@ def fs(monkeypatch):
|
||||
|
||||
async def _no_discord(token_ids):
|
||||
return 0
|
||||
monkeypatch.setattr(leases, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(discord_rest, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
monkeypatch.setattr(settings, "discord_guild_max_streams", 4)
|
||||
monkeypatch.setattr(settings, "discord_node_max_streams", 4)
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi.testclient import TestClient
|
||||
from app.config import settings
|
||||
from app.internal import discord_guild_link as glink
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal.auth import require_admin_token, require_firebase_token
|
||||
from app.main import app
|
||||
@@ -57,7 +58,7 @@ def fs(monkeypatch):
|
||||
|
||||
async def _no_discord(token_ids):
|
||||
return 0
|
||||
monkeypatch.setattr(leases, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(discord_rest, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
|
||||
for nid, org in (("n1", ORG), ("n2", ORG), ("nx", OTHER)):
|
||||
@@ -340,32 +341,21 @@ def test_remove_guild_can_make_pool_and_own_radio_bots_leave(fs, monkeypatch):
|
||||
"org_id": None, "guild_ids": ["300"], "valid": True})
|
||||
calls = []
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code):
|
||||
self.status_code = code
|
||||
def _handler(request):
|
||||
auth = request.headers["Authorization"]
|
||||
calls.append((auth, request.method, request.url.path))
|
||||
return httpx.Response(500 if auth == "Bot secret-t2" else 204)
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, headers, **kw):
|
||||
self.auth = headers["Authorization"]
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def delete(self, url):
|
||||
calls.append((self.auth, url))
|
||||
return _Resp(500 if self.auth == "Bot secret-t2" else 204)
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
||||
discord_rest.reset()
|
||||
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(_handler))
|
||||
monkeypatch.setattr(discord_rest, "_client_obj", None)
|
||||
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||
r = client.delete("/discord/guilds/100?leave_radio_bots=true")
|
||||
assert r.status_code == 200
|
||||
tried = sorted(a for a, _ in calls)
|
||||
tried = sorted(a for a, _, _ in calls)
|
||||
# pool bots in the server + this org's BYO; never another org's bot, never a bot not in it
|
||||
assert tried == ["Bot secret-mine", "Bot secret-t1", "Bot secret-t2"]
|
||||
assert all(u == "/users/@me/guilds/100" for _, u in calls)
|
||||
assert all(m == "DELETE" and u == "/api/v10/users/@me/guilds/100" for _, m, u in calls)
|
||||
rows = {x["name"]: x for x in r.json()["radio_bots"]}
|
||||
assert rows["Radio t1"]["left"] is True and rows["Radio t2"]["left"] is False
|
||||
assert "secret" not in r.text
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Discord REST client, cache and settings (server-26#225 slice S2).
|
||||
|
||||
Every request goes through httpx.MockTransport: nothing here talks to Discord.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_rest as rest
|
||||
from tests.fake_firestore import FakeFirestore
|
||||
|
||||
TOKEN = "Nzk5.SECRET-TOKEN-VALUE"
|
||||
G = "100000000000000001"
|
||||
BOT = "900000000000000009"
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
class Discord:
|
||||
"""A scripted Discord: ``routes[(method, path)]`` is a Response or a
|
||||
callable(request) -> Response. Records every request."""
|
||||
|
||||
def __init__(self):
|
||||
self.routes: dict = {}
|
||||
self.calls: list[httpx.Request] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.calls.append(request)
|
||||
path = request.url.path.removeprefix("/api/v10")
|
||||
handler = self.routes.get((request.method, path))
|
||||
if handler is None:
|
||||
return httpx.Response(404, json={"code": 0, "message": "no route in test"})
|
||||
return handler(request) if callable(handler) else handler
|
||||
|
||||
def paths(self):
|
||||
return [r.url.path.removeprefix("/api/v10") for r in self.calls]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def discord(monkeypatch):
|
||||
d = Discord()
|
||||
rest.reset()
|
||||
monkeypatch.setattr(rest, "_transport", httpx.MockTransport(d))
|
||||
monkeypatch.setattr(rest, "_client_obj", None)
|
||||
clock = Clock()
|
||||
monkeypatch.setattr(rest, "time", SimpleNamespace(monotonic=clock, time=clock))
|
||||
slept: list = []
|
||||
|
||||
async def _sleep(s):
|
||||
slept.append(s)
|
||||
clock.t += s
|
||||
monkeypatch.setattr(rest, "_sleep", _sleep)
|
||||
d.clock, d.slept = clock, slept
|
||||
yield d
|
||||
rest.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fs(monkeypatch):
|
||||
return FakeFirestore.install(monkeypatch)
|
||||
|
||||
|
||||
def _token_doc(fs, token_id="t1", *, guilds=None, valid=True, bot_user_id=BOT):
|
||||
doc = {"token_id": token_id, "name": "Radio 1", "token": TOKEN, "org_id": None,
|
||||
"guild_ids": [G] if guilds is None else guilds, "valid": valid, "bot_user_id": bot_user_id}
|
||||
fs.put("bot_tokens", token_id, doc)
|
||||
return dict(doc)
|
||||
|
||||
|
||||
def _guild_routes(d, guild=G):
|
||||
d.routes[("GET", f"/guilds/{guild}")] = httpx.Response(200, json={"id": guild, "owner_id": "1", "roles": []})
|
||||
d.routes[("GET", f"/guilds/{guild}/members/{BOT}")] = httpx.Response(200, json={"roles": []})
|
||||
d.routes[("GET", f"/guilds/{guild}/channels")] = httpx.Response(200, json=[{"id": "5", "type": 2}])
|
||||
|
||||
|
||||
# --- client hygiene ----------------------------------------------------------
|
||||
|
||||
async def test_requests_carry_user_agent_and_bot_auth(discord):
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(200, json={"id": BOT})
|
||||
r = await rest.request(TOKEN, "GET", "/users/@me")
|
||||
assert r.ok and r.data == {"id": BOT}
|
||||
(req,) = discord.calls
|
||||
assert req.headers["User-Agent"] == "DiscordBot (https://drb.cusano.net, 1)"
|
||||
assert req.headers["Authorization"] == f"Bot {TOKEN}"
|
||||
|
||||
|
||||
async def test_token_never_logged_or_in_errors(discord, fs, caplog):
|
||||
caplog.set_level(logging.DEBUG)
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(500, json={"message": "boom"})
|
||||
info = await rest.inspect_token(TOKEN)
|
||||
assert info["valid"] is None and info["check_error"]
|
||||
|
||||
def _boom(request):
|
||||
raise httpx.ConnectError("connection refused", request=request)
|
||||
discord.routes[("GET", f"/guilds/{G}")] = _boom
|
||||
snap = await rest.guild_snapshot(_token_doc(fs), G)
|
||||
assert snap.status == rest.UNKNOWN
|
||||
assert TOKEN not in (info["check_error"] or "") and TOKEN not in (snap.error or "")
|
||||
assert TOKEN not in caplog.text and "SECRET" not in caplog.text
|
||||
|
||||
|
||||
# --- /users/@me/guilds pagination --------------------------------------------
|
||||
|
||||
def _guild_pages(d, total):
|
||||
ids = [str(10**17 + i) for i in range(total)]
|
||||
|
||||
def _page(request):
|
||||
after = request.url.params.get("after")
|
||||
limit = int(request.url.params["limit"])
|
||||
rest_ids = [i for i in ids if after is None or int(i) > int(after)]
|
||||
return httpx.Response(200, json=[{"id": i} for i in rest_ids[:limit]])
|
||||
d.routes[("GET", "/users/@me/guilds")] = _page
|
||||
return ids
|
||||
|
||||
|
||||
async def test_guild_list_follows_after_pages(discord):
|
||||
ids = _guild_pages(discord, 450)
|
||||
r = await rest.list_guild_ids(TOKEN)
|
||||
assert r.ok and r.data == ids
|
||||
pages = [c for c in discord.calls if c.url.path.endswith("/users/@me/guilds")]
|
||||
assert len(pages) == 3
|
||||
assert pages[0].url.params.get("after") is None
|
||||
assert pages[1].url.params["after"] == ids[199] and pages[2].url.params["after"] == ids[399]
|
||||
|
||||
|
||||
async def test_guild_list_exact_multiple_of_page_size_stops_on_empty_page(discord):
|
||||
ids = _guild_pages(discord, 400)
|
||||
r = await rest.list_guild_ids(TOKEN)
|
||||
assert r.data == ids and len(discord.calls) == 3
|
||||
|
||||
|
||||
async def test_failed_page_is_unknown_not_a_truncated_list(discord):
|
||||
ids = [str(10**17 + i) for i in range(200)]
|
||||
|
||||
def _page(request):
|
||||
if request.url.params.get("after"):
|
||||
return httpx.Response(502)
|
||||
return httpx.Response(200, json=[{"id": i} for i in ids])
|
||||
discord.routes[("GET", "/users/@me/guilds")] = _page
|
||||
r = await rest.list_guild_ids(TOKEN)
|
||||
assert not r.ok and r.unknown and r.data is None
|
||||
|
||||
|
||||
async def test_inspect_token_reports_all_servers(discord):
|
||||
ids = _guild_pages(discord, 250)
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(200, json={"id": BOT, "username": "DRB Radio 1"})
|
||||
discord.routes[("GET", "/oauth2/applications/@me")] = httpx.Response(200, json={"id": "777"})
|
||||
info = await rest.inspect_token(TOKEN)
|
||||
assert info["valid"] is True and info["guild_ids"] == ids
|
||||
assert info["bot_user_id"] == BOT and info["bot_name"] == "DRB Radio 1" and info["application_id"] == "777"
|
||||
|
||||
|
||||
async def test_inspect_token_401_raises_and_5xx_is_unknown(discord):
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(401, json={"code": 0})
|
||||
with pytest.raises(rest.TokenRejected):
|
||||
await rest.inspect_token(TOKEN)
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(503)
|
||||
info = await rest.inspect_token(TOKEN)
|
||||
assert info["valid"] is None and info["guild_ids"] is None and "503" in info["check_error"]
|
||||
|
||||
|
||||
async def test_refresh_membership_writes_full_list_and_drops_cache(discord, fs):
|
||||
doc = _token_doc(fs, guilds=["1"])
|
||||
ids = _guild_pages(discord, 201)
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(200, json={"id": BOT, "username": "r"})
|
||||
rest.access_cache._entries[("t1", G, "snapshot")] = (10**9, "cached")
|
||||
assert await rest.refresh_membership(["t1"]) == 1
|
||||
assert fs.get("bot_tokens", "t1")["guild_ids"] == ids
|
||||
assert rest.access_cache.peek(("t1", G, "snapshot")) is None
|
||||
assert doc["token"] == fs.get("bot_tokens", "t1")["token"]
|
||||
|
||||
|
||||
# --- rate limits ---------------------------------------------------------------
|
||||
|
||||
async def test_short_429_is_retried_once(discord):
|
||||
seq = [httpx.Response(429, json={"retry_after": 1.5, "global": False}),
|
||||
httpx.Response(200, json={"id": BOT})]
|
||||
discord.routes[("GET", "/users/@me")] = lambda req: seq.pop(0)
|
||||
r = await rest.request(TOKEN, "GET", "/users/@me")
|
||||
assert r.ok and discord.slept == [1.5] and len(discord.calls) == 2
|
||||
|
||||
|
||||
async def test_second_429_or_long_retry_after_is_unknown(discord):
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(429, json={"retry_after": 1.0})
|
||||
r = await rest.request(TOKEN, "GET", "/users/@me")
|
||||
assert r.status == 429 and r.unknown and len(discord.calls) == 2
|
||||
|
||||
discord.calls.clear()
|
||||
discord.slept.clear()
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(429, json={"retry_after": 30})
|
||||
r = await rest.request(TOKEN, "GET", "/users/@me")
|
||||
assert r.unknown and len(discord.calls) == 1 and discord.slept == []
|
||||
|
||||
|
||||
async def test_global_429_pauses_only_that_token(discord):
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(
|
||||
429, json={"retry_after": 60, "global": True}, headers={"X-RateLimit-Global": "true"})
|
||||
assert (await rest.request(TOKEN, "GET", "/users/@me")).unknown
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(200, json={"id": BOT})
|
||||
r = await rest.request(TOKEN, "GET", "/users/@me")
|
||||
assert r.status is None and "rate limited" in r.error and len(discord.calls) == 1
|
||||
assert (await rest.request("other-token", "GET", "/users/@me")).ok
|
||||
discord.clock.t += 61
|
||||
assert (await rest.request(TOKEN, "GET", "/users/@me")).ok
|
||||
|
||||
|
||||
async def test_exhausted_bucket_is_per_token_and_guild(discord):
|
||||
other = "200000000000000002"
|
||||
hdrs = {"X-RateLimit-Bucket": "abc", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset-After": "60"}
|
||||
discord.routes[("GET", f"/guilds/{G}/channels")] = httpx.Response(200, json=[], headers=hdrs)
|
||||
discord.routes[("GET", f"/guilds/{other}/channels")] = httpx.Response(200, json=[], headers=hdrs)
|
||||
assert (await rest.request(TOKEN, "GET", f"/guilds/{G}/channels")).ok
|
||||
blocked = await rest.request(TOKEN, "GET", f"/guilds/{G}/channels")
|
||||
assert blocked.status is None and len(discord.calls) == 1 # not sent
|
||||
assert (await rest.request(TOKEN, "GET", f"/guilds/{other}/channels")).ok # other guild
|
||||
assert (await rest.request("tok-2", "GET", f"/guilds/{G}/channels")).ok # other token
|
||||
discord.clock.t += 61
|
||||
assert (await rest.request(TOKEN, "GET", f"/guilds/{G}/channels")).ok
|
||||
|
||||
|
||||
async def test_short_bucket_wait_is_slept_not_refused(discord):
|
||||
hdrs = {"X-RateLimit-Bucket": "abc", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset-After": "2"}
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(200, json={}, headers=hdrs)
|
||||
await rest.request(TOKEN, "GET", f"/guilds/{G}")
|
||||
assert (await rest.request(TOKEN, "GET", f"/guilds/{G}")).ok and discord.slept == [2.0]
|
||||
|
||||
|
||||
# --- invalid-request breaker -----------------------------------------------------
|
||||
|
||||
async def test_breaker_stops_all_requests_and_drains(discord, monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_rest_invalid_budget", 3)
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(401)
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(
|
||||
429, json={"retry_after": 99}, headers={"X-RateLimit-Scope": "shared"})
|
||||
for _ in range(4): # shared 429s never count
|
||||
await rest.request("t-x", "GET", f"/guilds/{G}")
|
||||
for _ in range(3):
|
||||
assert (await rest.request(TOKEN, "GET", "/users/@me")).status == 401
|
||||
sent = len(discord.calls)
|
||||
r = await rest.request("a-fresh-token", "GET", "/users/@me")
|
||||
assert r.status is None and "paused" in r.error and len(discord.calls) == sent
|
||||
discord.clock.t += settings.discord_rest_invalid_window_seconds + 1
|
||||
assert (await rest.request("a-fresh-token", "GET", "/users/@me")).status == 401
|
||||
|
||||
|
||||
async def test_403_and_unshared_429_count_toward_breaker(discord, monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_rest_invalid_budget", 2)
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(403, json={"code": 50013})
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(429, json={"retry_after": 99})
|
||||
await rest.request(TOKEN, "GET", f"/guilds/{G}")
|
||||
await rest.request("t2", "GET", "/users/@me")
|
||||
assert (await rest.request("t3", "GET", "/users/@me")).error.startswith("Discord REST paused")
|
||||
|
||||
|
||||
# --- per-(token, guild) snapshot + cache ---------------------------------------------
|
||||
|
||||
async def test_snapshot_reads_three_endpoints_and_caches(discord, fs, monkeypatch):
|
||||
_guild_routes(discord)
|
||||
tok = _token_doc(fs)
|
||||
snap = await rest.guild_snapshot(tok, G)
|
||||
assert snap.status == rest.OK and snap.channels == [{"id": "5", "type": 2}] and snap.member == {"roles": []}
|
||||
assert sorted(discord.paths()) == sorted([f"/guilds/{G}", f"/guilds/{G}/members/{BOT}", f"/guilds/{G}/channels"])
|
||||
assert (await rest.guild_snapshot(tok, G)) is snap and len(discord.calls) == 3
|
||||
discord.clock.t += settings.discord_access_ttl_seconds + 1
|
||||
await rest.guild_snapshot(tok, G)
|
||||
assert len(discord.calls) == 6
|
||||
assert rest.invalidate(guild_id=G) == 1
|
||||
await rest.guild_snapshot(tok, G)
|
||||
assert len(discord.calls) == 9
|
||||
await rest.guild_snapshot(tok, G, refresh=True)
|
||||
assert len(discord.calls) == 12
|
||||
|
||||
|
||||
async def test_snapshot_learns_and_stores_bot_user_id(discord, fs):
|
||||
_guild_routes(discord)
|
||||
discord.routes[("GET", "/users/@me")] = httpx.Response(200, json={"id": BOT})
|
||||
snap = await rest.guild_snapshot(_token_doc(fs, bot_user_id=None), G)
|
||||
assert snap.status == rest.OK and snap.bot_user_id == BOT
|
||||
assert fs.get("bot_tokens", "t1")["bot_user_id"] == BOT
|
||||
|
||||
|
||||
async def test_snapshot_401_marks_token_invalid_and_is_not_retried(discord, fs):
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(401, json={"code": 0})
|
||||
tok = _token_doc(fs)
|
||||
snap = await rest.guild_snapshot(tok, G)
|
||||
assert snap.status == rest.INVALID and fs.get("bot_tokens", "t1")["valid"] is False
|
||||
assert len(discord.calls) == 1
|
||||
discord.clock.t += settings.discord_access_negative_ttl_seconds - 1
|
||||
assert (await rest.guild_snapshot(tok, G)).status == rest.INVALID and len(discord.calls) == 1
|
||||
# an invalid token doc is never sent to Discord at all
|
||||
rest.invalidate()
|
||||
assert (await rest.guild_snapshot({**tok, "valid": False}, G)).status == rest.INVALID
|
||||
assert len(discord.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,path", [(10004, f"/guilds/{G}"), (10007, f"/guilds/{G}/members/{BOT}")])
|
||||
async def test_unknown_guild_or_member_drops_membership(discord, fs, code, path):
|
||||
_guild_routes(discord)
|
||||
discord.routes[("GET", path)] = httpx.Response(404, json={"code": code})
|
||||
snap = await rest.guild_snapshot(_token_doc(fs, guilds=[G, "7"]), G)
|
||||
assert snap.status == rest.NOT_MEMBER
|
||||
assert fs.get("bot_tokens", "t1")["guild_ids"] == ["7"]
|
||||
|
||||
|
||||
async def test_missing_access_confirms_with_one_membership_read(discord, fs):
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(403, json={"code": 50001})
|
||||
discord.routes[("GET", "/users/@me/guilds")] = httpx.Response(200, json=[{"id": "7"}])
|
||||
snap = await rest.guild_snapshot(_token_doc(fs, guilds=[G, "7"]), G)
|
||||
assert snap.status == rest.NOT_MEMBER
|
||||
assert fs.get("bot_tokens", "t1")["guild_ids"] == ["7"]
|
||||
assert discord.paths().count("/users/@me/guilds") == 1
|
||||
|
||||
|
||||
async def test_missing_access_alone_never_changes_membership(discord, fs):
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(403, json={"code": 50001})
|
||||
discord.routes[("GET", "/users/@me/guilds")] = httpx.Response(200, json=[{"id": G}])
|
||||
snap = await rest.guild_snapshot(_token_doc(fs, guilds=[G]), G)
|
||||
assert snap.status == rest.UNKNOWN and fs.get("bot_tokens", "t1")["guild_ids"] == [G]
|
||||
|
||||
rest.invalidate()
|
||||
discord.routes[("GET", "/users/@me/guilds")] = httpx.Response(503) # confirmation unknown too
|
||||
snap = await rest.guild_snapshot(_token_doc(fs, guilds=[G]), G)
|
||||
assert snap.status == rest.UNKNOWN and fs.get("bot_tokens", "t1")["guild_ids"] == [G]
|
||||
|
||||
|
||||
async def test_unknown_result_is_cached_briefly(discord, fs):
|
||||
discord.routes[("GET", f"/guilds/{G}")] = httpx.Response(503)
|
||||
tok = _token_doc(fs)
|
||||
assert (await rest.guild_snapshot(tok, G)).status == rest.UNKNOWN
|
||||
await rest.guild_snapshot(tok, G)
|
||||
assert len(discord.calls) == 1
|
||||
discord.clock.t += settings.discord_access_unknown_ttl_seconds + 1
|
||||
await rest.guild_snapshot(tok, G)
|
||||
assert len(discord.calls) == 2
|
||||
|
||||
|
||||
async def test_concurrent_loads_share_one_flight(discord, fs, monkeypatch):
|
||||
gate = asyncio.Event()
|
||||
_guild_routes(discord)
|
||||
tok = _token_doc(fs)
|
||||
real = rest._load_snapshot
|
||||
loads = []
|
||||
|
||||
async def _slow(token_doc, guild_id):
|
||||
loads.append(guild_id)
|
||||
await gate.wait()
|
||||
return await real(token_doc, guild_id)
|
||||
monkeypatch.setattr(rest, "_load_snapshot", _slow)
|
||||
tasks = [asyncio.create_task(rest.guild_snapshot(tok, G)) for _ in range(5)]
|
||||
await asyncio.sleep(0)
|
||||
gate.set()
|
||||
snaps = await asyncio.gather(*tasks)
|
||||
assert loads == [G] and len(discord.calls) == 3
|
||||
assert all(s is snaps[0] for s in snaps)
|
||||
|
||||
|
||||
async def test_invalidate_during_a_load_is_not_undone(discord, fs, monkeypatch):
|
||||
gate = asyncio.Event()
|
||||
_guild_routes(discord)
|
||||
tok = _token_doc(fs)
|
||||
real = rest._load_snapshot
|
||||
|
||||
async def _slow(token_doc, guild_id):
|
||||
await gate.wait()
|
||||
return await real(token_doc, guild_id)
|
||||
monkeypatch.setattr(rest, "_load_snapshot", _slow)
|
||||
task = asyncio.create_task(rest.guild_snapshot(tok, G))
|
||||
await asyncio.sleep(0)
|
||||
rest.invalidate(token_id="t1")
|
||||
gate.set()
|
||||
assert (await task).status == rest.OK
|
||||
assert rest.access_cache.peek(("t1", G, "snapshot")) is None
|
||||
|
||||
|
||||
def test_invalidate_scopes():
|
||||
c = rest.AccessCache()
|
||||
for k in [("t1", "g1", "s"), ("t1", "g2", "s"), ("t2", "g1", "s")]:
|
||||
c._entries[k] = (10**12, k)
|
||||
assert c.invalidate(token_id="t1", guild_id="g1") == 1
|
||||
assert c.invalidate(guild_id="g1") == 1
|
||||
assert c.invalidate(token_id="t1") == 1
|
||||
assert c.invalidate() == 0
|
||||
|
||||
|
||||
def test_ttl_for_outcomes():
|
||||
assert rest.ttl_for(rest.OK) == 120
|
||||
assert rest.ttl_for(rest.DENIED) == rest.ttl_for(rest.NOT_MEMBER) == rest.ttl_for(rest.INVALID) == 600
|
||||
assert rest.ttl_for(rest.UNKNOWN) == 30
|
||||
|
||||
|
||||
def test_225_settings_defaults():
|
||||
assert settings.discord_perm_check_enabled is True
|
||||
assert settings.discord_access_ttl_seconds == 120
|
||||
assert settings.discord_rest_invalid_budget == 4000
|
||||
assert settings.discord_oauth_redirect_uri == ""
|
||||
assert settings.discord_pool_bot_guild_cap == 90
|
||||
|
||||
|
||||
# --- leave_guild on the shared client ---------------------------------------------
|
||||
|
||||
async def test_leave_guild_drops_cache_and_reports_unknown(discord, fs):
|
||||
_token_doc(fs, "t1", guilds=[G])
|
||||
fs.put("bot_tokens", "t2", {"token_id": "t2", "name": "R2", "token": "tok-two", "org_id": None,
|
||||
"guild_ids": None, "valid": True})
|
||||
rest.access_cache._entries[("t1", G, "snapshot")] = (10**12, "x")
|
||||
|
||||
def _delete(request):
|
||||
if request.headers["Authorization"] == "Bot tok-two":
|
||||
raise httpx.ReadTimeout("timed out", request=request)
|
||||
return httpx.Response(204)
|
||||
discord.routes[("DELETE", f"/users/@me/guilds/{G}")] = _delete
|
||||
rows = {r["token_id"]: r for r in await rest.leave_guild(G, None)}
|
||||
assert rows["t1"]["left"] is True and rows["t2"]["left"] is False
|
||||
assert "ReadTimeout" in rows["t2"]["error"] and "tok-two" not in rows["t2"]["error"]
|
||||
assert fs.get("bot_tokens", "t1")["guild_ids"] == []
|
||||
assert rest.access_cache.peek(("t1", G, "snapshot")) is None
|
||||
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from app.models import CommandPayload
|
||||
from app.routers import discord_web as web
|
||||
@@ -43,7 +44,7 @@ def fs(monkeypatch):
|
||||
|
||||
async def _no_discord(token_ids):
|
||||
return 0
|
||||
monkeypatch.setattr(leases, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(discord_rest, "refresh_membership", _no_discord)
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
fake.published = published
|
||||
|
||||
|
||||
Reference in New Issue
Block a user