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

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:
Lotus CI
2026-10-19 23:30:15 -04:00
co-authored by Claude Opus 5.5
parent f7ce35c636
commit d3f389cab1
3 changed files with 154 additions and 2 deletions
+78
View File
@@ -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, [])