Files
Logan CusanoandClaude Opus 5.5 9e850fedf0 Discord perms: partial guild payload is unknown, not a deny (#225 S1 review)
A guild without its @everyone role (or that role's permissions) was read
as base 0, a definite deny that would block a start. It now raises
ValueError, which S3 treats as unknown and lets the start go ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:57:15 -04:00

289 lines
11 KiB
Python

"""
Discord channel permission math (server-26#225, slice S1).
Payloads are shaped like Discord's REST JSON: permission integers are
strings, overwrite ``type`` is 0 (role) / 1 (member).
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.internal import discord_leases as leases
from app.internal import discord_perms as perms
from app.internal.discord_perms import (
ADMINISTRATOR, ALL_PERMISSIONS, CONNECT, MOVE_MEMBERS, READ_MESSAGE_HISTORY,
SPEAK, VIEW_CHANNEL, check_voice, effective_permissions, parse_permissions,
)
GUILD = "100"
BOT = "900"
OWNER = "1"
MUTED = "201"
BOTS = "202"
DJ = "203"
NOW = datetime(2026, 10, 5, 12, 0, tzinfo=timezone.utc)
VOICE = VIEW_CHANNEL | CONNECT | SPEAK
def guild(everyone=VOICE, extra_roles=()):
roles = [{"id": GUILD, "name": "@everyone", "permissions": str(everyone)}]
roles += [{"id": rid, "name": name, "permissions": str(p)} for rid, name, p in extra_roles]
return {"id": GUILD, "owner_id": OWNER, "roles": roles}
def member(roles=(), uid=BOT, timeout=None):
return {"user": {"id": uid}, "roles": list(roles), "communication_disabled_until": timeout}
def ow(oid, kind, allow=0, deny=0):
return {"id": oid, "type": kind, "allow": str(allow), "deny": str(deny)}
def channel(overwrites=(), type_=2, user_limit=0, name="dispatch"):
return {"id": "500", "name": name, "type": type_, "user_limit": user_limit,
"permission_overwrites": list(overwrites)}
def eff(g, m, c):
return effective_permissions(g, m, c, now=NOW)
def test_required_bits_match_radio_bot_invite():
assert VIEW_CHANNEL | CONNECT | SPEAK == leases.RADIO_BOT_PERMISSIONS
def test_plain_everyone_grant_is_ok():
bits, trace = eff(guild(), member(), channel())
assert bits == VOICE and trace["Move Members"]
r = check_voice(bits, channel(), trace)
assert r == {"ok": True, "is_voice": True, "missing": [], "reasons": {},
"user_limit": 0, "limit_bypass": False}
def test_owner_gets_everything_despite_denies():
c = channel([ow(GUILD, 0, deny=VOICE), ow(OWNER, 1, deny=VOICE)])
bits, trace = eff(guild(everyone=0), member(uid=OWNER), c)
assert bits == ALL_PERMISSIONS and trace == {}
def test_administrator_role_bypasses_overwrites():
g = guild(everyone=0, extra_roles=[(BOTS, "Bots", ADMINISTRATOR)])
c = channel([ow(GUILD, 0, deny=VOICE), ow(BOT, 1, deny=VOICE)])
bits, _ = eff(g, member([BOTS]), c)
assert bits == ALL_PERMISSIONS
assert check_voice(bits, c)["ok"] and check_voice(bits, c)["limit_bypass"]
def test_admin_on_everyone_counts_too():
bits, _ = eff(guild(everyone=ADMINISTRATOR), member(), channel())
assert bits == ALL_PERMISSIONS
def test_base_is_everyone_or_member_roles_only():
# A role the bot doesn't hold grants nothing.
g = guild(everyone=VIEW_CHANNEL, extra_roles=[(DJ, "DJ", CONNECT | SPEAK), (BOTS, "Bots", 0)])
bits, trace = eff(g, member([BOTS]), channel())
assert bits == VIEW_CHANNEL
assert check_voice(bits, channel(), trace)["missing"] == ["Connect", "Speak"]
assert "not granted by @everyone or any of the bot's roles" in trace["Connect"]
bits, _ = eff(g, member([DJ]), channel())
assert bits == VOICE
def test_everyone_overwrite_deny_then_allow():
c = channel([ow(GUILD, 0, deny=SPEAK)])
bits, trace = eff(guild(), member(), c)
assert check_voice(bits, c, trace)["missing"] == ["Speak"]
assert trace["Speak"] == "denied by #dispatch's override for @everyone"
# An allow in the same overwrite restores a bit the base lacked.
c2 = channel([ow(GUILD, 0, allow=SPEAK)])
bits, _ = eff(guild(everyone=VIEW_CHANNEL | CONNECT), member(), c2)
assert bits == VOICE
def test_role_overwrite_denial_is_named():
g = guild(extra_roles=[(MUTED, "Muted", 0)])
c = channel([ow(MUTED, 0, deny=SPEAK)])
bits, trace = eff(g, member([MUTED]), c)
r = check_voice(bits, c, trace)
assert not r["ok"] and r["missing"] == ["Speak"]
assert r["reasons"]["Speak"] == "denied by #dispatch's override for @Muted"
def test_role_overwrite_for_role_bot_lacks_is_ignored():
g = guild(extra_roles=[(MUTED, "Muted", 0)])
c = channel([ow(MUTED, 0, deny=SPEAK)])
bits, _ = eff(g, member([]), c)
assert bits == VOICE
def test_role_allow_beats_role_deny_across_roles():
g = guild(extra_roles=[(MUTED, "Muted", 0), (DJ, "DJ", 0)])
c = channel([ow(MUTED, 0, deny=SPEAK), ow(DJ, 0, allow=SPEAK)])
bits, trace = eff(g, member([MUTED, DJ]), c)
assert bits & SPEAK and "Speak" not in trace
def test_role_overwrites_override_everyone_overwrite():
g = guild(extra_roles=[(DJ, "DJ", 0)])
c = channel([ow(GUILD, 0, deny=CONNECT | SPEAK), ow(DJ, 0, allow=CONNECT | SPEAK)])
bits, trace = eff(g, member([DJ]), c)
assert bits == VOICE and trace.get("Connect") is None
def test_several_denying_roles_are_all_named():
g = guild(extra_roles=[(MUTED, "Muted", 0), (BOTS, "Bots", 0)])
c = channel([ow(MUTED, 0, deny=CONNECT), ow(BOTS, 0, deny=CONNECT)])
_, trace = eff(g, member([MUTED, BOTS]), c)
assert trace["Connect"] == "denied by #dispatch's override for @Muted, @Bots"
def test_member_overwrite_applies_last():
g = guild(extra_roles=[(DJ, "DJ", 0)])
c = channel([ow(DJ, 0, allow=SPEAK), ow(BOT, 1, deny=SPEAK)])
bits, trace = eff(g, member([DJ]), c)
assert not bits & SPEAK
assert trace["Speak"] == "denied by #dispatch's override for the bot itself"
# ...and its allow beats a role deny.
c2 = channel([ow(GUILD, 0, deny=SPEAK), ow(BOT, 1, allow=SPEAK)])
bits, _ = eff(guild(), member(), c2)
assert bits == VOICE
def test_member_overwrite_for_another_user_is_ignored():
c = channel([ow("777", 1, deny=VOICE)])
bits, _ = eff(guild(), member(), c)
assert bits == VOICE
def test_no_view_channel_means_nothing():
c = channel([ow(GUILD, 0, deny=VIEW_CHANNEL)])
bits, trace = eff(guild(everyone=VOICE | MOVE_MEMBERS), member(), c)
assert bits == 0
r = check_voice(bits, c, trace)
assert r["missing"] == ["View Channel", "Connect", "Speak"] and not r["limit_bypass"]
assert trace["View Channel"] == "denied by #dispatch's override for @everyone"
assert trace["Connect"] == "the bot can't see #dispatch without View Channel"
def test_no_connect_takes_speak_and_move_in_voice():
c = channel([ow(GUILD, 0, deny=CONNECT)])
bits, trace = eff(guild(everyone=VOICE | MOVE_MEMBERS), member(), c)
assert bits == VIEW_CHANNEL
assert check_voice(bits, c, trace)["missing"] == ["Connect", "Speak"]
assert trace["Connect"] == "denied by #dispatch's override for @everyone"
assert trace["Speak"] == "Discord removes it in #dispatch without Connect"
def test_earlier_cause_survives_later_implicit_denial():
# Speak denied by a role AND later wiped by missing View: the trace keeps
# the last step that removed it, which is the View wipe only when Speak
# was still present at that point.
g = guild(extra_roles=[(MUTED, "Muted", 0)])
c = channel([ow(MUTED, 0, deny=SPEAK), ow(BOT, 1, deny=VIEW_CHANNEL)])
_, trace = eff(g, member([MUTED]), c)
assert trace["Speak"] == "denied by #dispatch's override for @Muted"
assert trace["Connect"] == "the bot can't see #dispatch without View Channel"
def test_timed_out_keeps_only_view_and_history():
future = (NOW + timedelta(hours=1)).isoformat()
g = guild(everyone=VOICE | READ_MESSAGE_HISTORY | MOVE_MEMBERS)
bits, trace = eff(g, member(timeout=future), channel())
assert bits == VIEW_CHANNEL | READ_MESSAGE_HISTORY
assert trace["Connect"] == "the bot is timed out in this server"
past = (NOW - timedelta(hours=1)).isoformat().replace("+00:00", "Z")
bits, _ = eff(g, member(timeout=past), channel())
assert bits & VOICE == VOICE
def test_unparseable_timeout_is_ignored():
bits, _ = eff(guild(), member(timeout="not-a-date"), channel())
assert bits == VOICE
def test_permission_strings_above_2_53():
big = (1 << 60) | (1 << 53) | 1
assert parse_permissions(str(big)) == big
assert parse_permissions(str((1 << 53) + 1)) == (1 << 53) + 1
# Float rounding would turn this into 2**53 and drop the low bit.
assert parse_permissions("9007199254740993") == 9007199254740993
g = guild(everyone=VOICE | (1 << 60))
c = channel([ow(GUILD, 0, allow=1 << 55, deny=1 << 60)])
bits, _ = eff(g, member(), c)
assert bits == VOICE | (1 << 55)
@pytest.mark.parametrize("value", ["abc", "-5", "1.5", True, 3.0, -1, [], {}])
def test_malformed_permission_values_raise(value):
with pytest.raises(ValueError):
parse_permissions(value)
def test_missing_permission_values_are_zero():
assert parse_permissions(None) == 0
assert parse_permissions("") == 0
assert parse_permissions(7) == 7
def test_malformed_payload_raises_not_silently_denies():
g = guild()
g["roles"][0]["permissions"] = "garbage"
with pytest.raises(ValueError):
eff(g, member(), channel())
def test_legacy_string_overwrite_types():
g = guild(extra_roles=[(MUTED, "Muted", 0)])
c = channel([ow(MUTED, "role", deny=SPEAK), ow(BOT, "member", deny=CONNECT)])
_, trace = eff(g, member([MUTED]), c)
assert set(trace) >= {"Connect", "Speak"}
def test_unknown_member_role_ids_are_ignored():
bits, _ = eff(guild(), member(["999999"]), channel())
assert bits == VOICE
@pytest.mark.parametrize("g", [
{"id": GUILD, "owner_id": OWNER, "roles": []},
{"id": GUILD, "owner_id": OWNER},
{"id": GUILD, "owner_id": OWNER, "roles": [{"id": GUILD, "name": "@everyone"}]},
{"owner_id": OWNER, "roles": [{"id": GUILD, "name": "@everyone", "permissions": "0"}]},
])
def test_partial_guild_payload_raises_not_silently_denies(g):
# A guild without its @everyone role (or its permissions) is a partial
# payload: "unknown" (ValueError, start goes ahead), never a definite deny.
with pytest.raises(ValueError):
eff(g, member(), channel())
def test_everyone_role_granting_nothing_is_a_real_deny():
bits, trace = eff(guild(everyone=0), member(), channel())
assert bits == 0 and "not granted" in trace["View Channel"]
def test_check_voice_only_counts_type_2():
for t in (0, 13, 4):
r = check_voice(ALL_PERMISSIONS, channel(type_=t))
assert r["ok"] is False and r["is_voice"] is False and r["missing"] == []
def test_check_voice_user_limit_and_bypass():
c = channel(user_limit=5)
assert check_voice(VOICE, c)["user_limit"] == 5
assert check_voice(VOICE, c)["limit_bypass"] is False
assert check_voice(VOICE | MOVE_MEMBERS, c)["limit_bypass"] is True
assert check_voice(VOICE, channel(user_limit=None))["user_limit"] == 0
def test_labels_match_edge_join_refusal_strings():
# node-26#38 discord_radio._join_refusal emits exactly these labels.
assert [label for _, label in perms.REQUIRED_VOICE] == ["View Channel", "Connect", "Speak"]
def test_now_defaults_to_current_time():
future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
bits, _ = effective_permissions(guild(), member(timeout=future), channel())
assert bits == VIEW_CHANNEL