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>
289 lines
11 KiB
Python
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
|