feat(voice-limit-guard): enforce the limit at connect time too (#25)
Lint / Shell (shellcheck) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 6s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 9s
Lint / Shell (shellcheck) (pull_request) Successful in 13s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / No secrets in webhook configs (pull_request) Successful in 10s
Lint / Landing page is rendered (matrix (pull_request) Successful in 11s
Lint / Python (ruff) (pull_request) Successful in 10s
Lint / Python deps (pip-audit) (pull_request) Successful in 50s
Lint / Secret scan (gitleaks) (pull_request) Successful in 5s
Lint / Shell (shellcheck) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 6s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 9s
Lint / Shell (shellcheck) (pull_request) Successful in 13s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / No secrets in webhook configs (pull_request) Successful in 10s
Lint / Landing page is rendered (matrix (pull_request) Successful in 11s
Lint / Python (ruff) (pull_request) Successful in 10s
Lint / Python deps (pip-audit) (pull_request) Successful in 50s
Lint / Secret scan (gitleaks) (pull_request) Successful in 5s
The limit was only checked at token issue, and lk-jwt tokens last an hour, so a token fetched while there was space could be used to connect once the room was full. The reconcile loop now compares each limited room's distinct Matrix users with max_users and RemoveParticipants the people who arrived since the previous sweep, newest first, until it is back within the limit. People already in the call are never removed (lowering a limit mid-call kicks nobody); extra devices of one person never count twice. Fixes #25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f7ce35c636
commit
d3f389cab1
@@ -417,3 +417,81 @@ class SlotReservationTests(unittest.TestCase):
|
||||
r = guard.SlotReservations()
|
||||
self.assertIs(r.room_lock("a"), r.room_lock("a"))
|
||||
self.assertIsNot(r.room_lock("a"), r.room_lock("b"))
|
||||
|
||||
|
||||
def _p(identity, joined_ms):
|
||||
return {"identity": identity, "joined_at_ms": str(joined_ms),
|
||||
"permission": {"canPublish": True, "canPublishSources": ["MICROPHONE"]}}
|
||||
|
||||
|
||||
class TestOverLimitRemovals(unittest.TestCase):
|
||||
"""[matrix #25] connect-time enforcement of the voice limit."""
|
||||
|
||||
def test_first_sweep_records_and_removes_nobody(self):
|
||||
parts = [_p("@a:x:DEV1", 1), _p("@b:x:DEV1", 2)]
|
||||
removals, seen = guard.over_limit_removals(parts, 1, None)
|
||||
self.assertEqual(removals, [])
|
||||
self.assertEqual(seen, {"@a:x", "@b:x"})
|
||||
|
||||
def test_a_newcomer_past_the_limit_is_removed_with_all_devices(self):
|
||||
parts = [_p("@a:x:DEV1", 1), _p("@b:x:DEV1", 5), _p("@b:x:DEV2", 6)]
|
||||
removals, seen = guard.over_limit_removals(parts, 1, {"@a:x"})
|
||||
self.assertEqual(sorted(removals), ["@b:x:DEV1", "@b:x:DEV2"])
|
||||
self.assertEqual(seen, {"@a:x"})
|
||||
|
||||
def test_newest_newcomers_go_first(self):
|
||||
parts = [_p("@a:x:D", 1), _p("@b:x:D", 10), _p("@c:x:D", 20)]
|
||||
removals, _ = guard.over_limit_removals(parts, 2, {"@a:x"})
|
||||
self.assertEqual(removals, ["@c:x:D"])
|
||||
|
||||
def test_lowering_the_limit_mid_call_kicks_nobody(self):
|
||||
parts = [_p("@a:x:D", 1), _p("@b:x:D", 2), _p("@c:x:D", 3)]
|
||||
removals, seen = guard.over_limit_removals(parts, 1, {"@a:x", "@b:x", "@c:x"})
|
||||
self.assertEqual(removals, [])
|
||||
self.assertEqual(seen, {"@a:x", "@b:x", "@c:x"})
|
||||
|
||||
def test_extra_devices_of_a_present_user_are_not_newcomers(self):
|
||||
parts = [_p("@a:x:DEV1", 1), _p("@a:x:DEV2", 9)]
|
||||
removals, _ = guard.over_limit_removals(parts, 1, {"@a:x"})
|
||||
self.assertEqual(removals, [])
|
||||
|
||||
def test_no_limit_removes_nobody(self):
|
||||
parts = [_p("@a:x:D", 1), _p("@b:x:D", 2)]
|
||||
self.assertEqual(guard.over_limit_removals(parts, 0, {"@a:x"})[0], [])
|
||||
|
||||
def test_joined_at_seconds_are_understood(self):
|
||||
parts = [{"identity": "@a:x:D", "joined_at": "100"}, {"identity": "@b:x:D", "joined_at": "200"},
|
||||
{"identity": "@c:x:D", "joinedAt": "150"}]
|
||||
removals, _ = guard.over_limit_removals(parts, 1, set())
|
||||
self.assertEqual(sorted(removals), ["@b:x:D", "@c:x:D"])
|
||||
|
||||
|
||||
class TestReconcileRoomLimit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig = (guard.room_state, guard.livekit_list_participants,
|
||||
guard.livekit_update_participant, guard.livekit_remove_participant)
|
||||
self.removed, self.updates = [], []
|
||||
guard.livekit_remove_participant = lambda a, i: self.removed.append(i)
|
||||
guard.livekit_update_participant = lambda a, i, p: self.updates.append(i)
|
||||
guard._seen_users.clear()
|
||||
|
||||
def tearDown(self):
|
||||
(guard.room_state, guard.livekit_list_participants,
|
||||
guard.livekit_update_participant, guard.livekit_remove_participant) = self._orig
|
||||
guard._seen_users.clear()
|
||||
|
||||
def test_a_late_connect_with_an_early_token_is_removed(self):
|
||||
guard.room_state = lambda rid, max_age=0: {"max_users": 1, "allow_screenshare": True, "allow_camera": True}
|
||||
guard.livekit_list_participants = lambda a: [_p("@a:x:D", 1)]
|
||||
guard.reconcile_room("alias", "!room:x") # first sweep: @a is there
|
||||
self.assertEqual(self.removed, [])
|
||||
guard.livekit_list_participants = lambda a: [_p("@a:x:D", 1), _p("@late:x:D", 50)]
|
||||
guard.reconcile_room("alias", "!room:x")
|
||||
self.assertEqual(self.removed, ["@late:x:D"])
|
||||
self.assertEqual(self.updates, []) # nothing forbidden in this room
|
||||
|
||||
def test_unlimited_unrestricted_room_is_not_listed(self):
|
||||
guard.room_state = lambda rid, max_age=0: {"max_users": 0, "allow_screenshare": True, "allow_camera": True}
|
||||
guard.livekit_list_participants = lambda a: (_ for _ in ()).throw(AssertionError("listed"))
|
||||
guard.reconcile_room("alias", "!room:x")
|
||||
self.assertEqual(self.removed, [])
|
||||
|
||||
Reference in New Issue
Block a user