From f4c33a1885e20f80ed3e79ceea9db1264acb1409 Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Thu, 4 Sep 2025 14:23:52 +1000 Subject: [PATCH 1/4] fix not setting inviter to a user that already left the room --- synapse_room_code/get_inviter_user.py | 8 +- tests/test_e2e.py | 283 ++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 1 deletion(-) diff --git a/synapse_room_code/get_inviter_user.py b/synapse_room_code/get_inviter_user.py index 9af1bd1..724fd12 100644 --- a/synapse_room_code/get_inviter_user.py +++ b/synapse_room_code/get_inviter_user.py @@ -11,6 +11,7 @@ USERS_DEFAULT_POWER_LEVEL_KEY, USERS_POWER_LEVEL_KEY, ) +from synapse_room_code.user_is_room_member import user_is_room_member async def get_inviter_user(api: ModuleApi, room_id: str) -> Optional[UserID]: @@ -57,7 +58,7 @@ async def get_inviter_user(api: ModuleApi, room_id: str) -> Optional[UserID]: if not isinstance(users_power_level, dict): users_power_level = {} - # Find the user with the highest power level + # Find the user with the highest power level that is still a member of the room local_user_id_with_highest_power = None highest_local_power = users_default for user_id, power_level in users_power_level.items(): @@ -71,6 +72,11 @@ async def get_inviter_user(api: ModuleApi, room_id: str) -> Optional[UserID]: if not isinstance(user_id, str): continue + # ensure user is a member of the room + is_member = await user_is_room_member(api=api, user_id=user_id, room_id=room_id) + if not is_member: + continue + if power_level > highest_local_power and api.is_mine(user_id): highest_local_power = power_level local_user_id_with_highest_power = user_id diff --git a/tests/test_e2e.py b/tests/test_e2e.py index aebc1a4..8357cd4 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -295,6 +295,59 @@ async def wait_for_room_invitation( total_wait_time += wait_interval return received_invitation + async def set_room_power_levels( + self, room_id: str, access_token: str, user_power_levels: dict + ): + headers = {"Authorization": f"Bearer {access_token}"} + set_power_levels_url = f"http://localhost:8008/_matrix/client/v3/rooms/{room_id}/state/m.room.power_levels" + power_levels_content = { + "users": user_power_levels, + "users_default": 0, + "events": {}, + "events_default": 0, + "state_default": 50, + "ban": 50, + "kick": 50, + "redact": 50, + "invite": 50, + } + response = requests.put( + set_power_levels_url, + json=power_levels_content, + headers=headers, + ) + self.assertEqual(response.status_code, 200) + event_id = response.json()["event_id"] + self.assertIsInstance(event_id, str) + return event_id + + async def join_room(self, room_id: str, access_token: str): + headers = {"Authorization": f"Bearer {access_token}"} + join_room_url = f"http://localhost:8008/_matrix/client/v3/rooms/{room_id}/join" + response = requests.post(join_room_url, json={}, headers=headers) + self.assertEqual(response.status_code, 200) + room_id_response = response.json()["room_id"] + self.assertIsInstance(room_id_response, str) + return room_id_response + + async def invite_user_to_room(self, room_id: str, user_id: str, access_token: str): + headers = {"Authorization": f"Bearer {access_token}"} + invite_user_url = ( + f"http://localhost:8008/_matrix/client/v3/rooms/{room_id}/invite" + ) + response = requests.post( + invite_user_url, json={"user_id": user_id}, headers=headers + ) + self.assertEqual(response.status_code, 200) + + async def leave_room(self, room_id: str, access_token: str): + headers = {"Authorization": f"Bearer {access_token}"} + leave_room_url = ( + f"http://localhost:8008/_matrix/client/v3/rooms/{room_id}/leave" + ) + response = requests.post(leave_room_url, json={}, headers=headers) + self.assertEqual(response.status_code, 200) + async def start_test_postgres(self): postgresql = None try: @@ -456,6 +509,117 @@ async def test_e2e_knock_with_code_sqlite(self) -> None: shutil.rmtree(synapse_dir) raise e + async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: + synapse_dir = None + server_process = None + stdout_thread = None + stderr_thread = None + try: + # Create a temporary directory for the Synapse server + access_code = "vldcde1" + ( + synapse_dir, + config_path, + server_process, + stdout_thread, + stderr_thread, + ) = await self.start_test_synapse() + await self.register_user( + config_path=config_path, + dir=synapse_dir, + user="test1", + password="123123123", + admin=True, + ) + await self.register_user( + config_path=config_path, + dir=synapse_dir, + user="test2", + password="123123123", + admin=True, + ) + await self.register_user( + config_path=config_path, + dir=synapse_dir, + user="test3", + password="123123123", + admin=True, + ) + + # Login to obtain access token of all users + user_1_id, user_1_access_token = await self.login_user( + user="test1", password="123123123" + ) + user_2_id, user_2_access_token = await self.login_user( + user="test2", password="123123123" + ) + user_3_id, user_3_access_token = await self.login_user( + user="test3", password="123123123" + ) + + room_id = await self.create_private_room(user_1_access_token) + + # User 2 needs to be invited and then join the room first (required before they can leave) + await self.invite_user_to_room( + room_id=room_id, user_id=user_2_id, access_token=user_1_access_token + ) + await self.join_room(room_id=room_id, access_token=user_2_access_token) + + # Set power levels: user1 = 100 (room creator), user2 = 100, user3 = 0 + await self.set_room_power_levels( + room_id=room_id, + access_token=user_1_access_token, + user_power_levels={ + user_1_id: 100, + user_2_id: 100, + }, + ) + + # User 2 (with highest power level besides creator) leaves the room + await self.leave_room(room_id=room_id, access_token=user_2_access_token) + + await self.set_room_knockable_with_code( + room_id=room_id, + access_token=user_1_access_token, + access_code=access_code, + ) + + # Invoke knock with code endpoint - should still work because user1 is still in the room + await self.knock_with_code(access_code, user_3_access_token) + + # Wait for the invite - should work because user1 is still available to invite + received_invitation = await self.wait_for_room_invitation( + room_id=room_id, + user_id=user_3_id, + access_token=user_1_access_token, + ) + if not received_invitation: + self.fail("User 3 was not invited to the room") + else: + print("User 3 was invited to the room successfully after admin left") + + # Clean up + if server_process is not None: + server_process.terminate() + server_process.wait() + if stdout_thread is not None: + stdout_thread.join() + if stderr_thread is not None: + stderr_thread.join() + if synapse_dir is not None: + shutil.rmtree(synapse_dir) + except Exception as e: + if server_process is not None: + server_process.terminate() + server_process.wait() + if stdout_thread is not None: + stdout_thread.join() + if stderr_thread is not None: + stderr_thread.join() + if synapse_dir is not None: + shutil.rmtree(synapse_dir) + raise e + async def test_e2e_knock_with_code_postgresql(self) -> None: postgres = None server_process = None @@ -548,6 +712,125 @@ async def test_e2e_knock_with_code_postgresql(self) -> None: shutil.rmtree(synapse_dir) raise e + async def test_e2e_knock_with_code_admin_left_postgresql(self) -> None: + postgres = None + server_process = None + stdout_thread = None + stderr_thread = None + synapse_dir = None + try: + # Create a temporary directory for the Synapse server + access_code = "vldcde1" + postgres, postgres_url = await self.start_test_postgres() + ( + synapse_dir, + config_path, + server_process, + stdout_thread, + stderr_thread, + ) = await self.start_test_synapse( + db="postgresql", postgresql_url=postgres_url + ) + await self.register_user( + config_path=config_path, + dir=synapse_dir, + user="test1", + password="123123123", + admin=True, + ) + await self.register_user( + config_path=config_path, + dir=synapse_dir, + user="test2", + password="123123123", + admin=True, + ) + await self.register_user( + config_path=config_path, + dir=synapse_dir, + user="test3", + password="123123123", + admin=True, + ) + + # Login to obtain access token of all users + user_1_id, user_1_access_token = await self.login_user( + user="test1", password="123123123" + ) + user_2_id, user_2_access_token = await self.login_user( + user="test2", password="123123123" + ) + user_3_id, user_3_access_token = await self.login_user( + user="test3", password="123123123" + ) + + room_id = await self.create_private_room(user_1_access_token) + + # User 2 needs to be invited and then join the room first (required before they can leave) + await self.invite_user_to_room( + room_id=room_id, user_id=user_2_id, access_token=user_1_access_token + ) + await self.join_room(room_id=room_id, access_token=user_2_access_token) + + # Set power levels: user1 = 100 (room creator), user2 = 100, user3 = 0 + await self.set_room_power_levels( + room_id=room_id, + access_token=user_1_access_token, + user_power_levels={ + user_1_id: 100, + user_2_id: 100, + }, + ) + + # User 2 (with highest power level besides creator) leaves the room + await self.leave_room(room_id=room_id, access_token=user_2_access_token) + + await self.set_room_knockable_with_code( + room_id=room_id, + access_token=user_1_access_token, + access_code=access_code, + ) + + # Invoke knock with code endpoint - should still work because user1 is still in the room + await self.knock_with_code(access_code, user_3_access_token) + + # Wait for the invite - should work because user1 is still available to invite + received_invitation = await self.wait_for_room_invitation( + room_id=room_id, + user_id=user_3_id, + access_token=user_1_access_token, + ) + if not received_invitation: + self.fail("User 3 was not invited to the room") + else: + print("User 3 was invited to the room successfully after admin left") + + # Clean up + if postgres is not None: + postgres.stop() + if server_process is not None: + server_process.terminate() + server_process.wait() + if stdout_thread is not None: + stdout_thread.join() + if stderr_thread is not None: + stderr_thread.join() + if synapse_dir is not None: + shutil.rmtree(synapse_dir) + except Exception as e: + if postgres is not None: + postgres.stop() + if server_process is not None: + server_process.terminate() + server_process.wait() + if stdout_thread is not None: + stdout_thread.join() + if stderr_thread is not None: + stderr_thread.join() + if synapse_dir is not None: + shutil.rmtree(synapse_dir) + raise e + async def get_access_token_without_access_code(self): get_access_token_url = ( "http://localhost:8008/_synapse/client/pangea/v1/request_room_code" From 3e4b887a9dd0d1987fbd6c5fbc5c611bca63d346 Mon Sep 17 00:00:00 2001 From: Wilson Date: Thu, 4 Sep 2025 00:27:56 -0400 Subject: [PATCH 2/4] use logger in test Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 8357cd4..083c838 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -596,7 +596,7 @@ async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: if not received_invitation: self.fail("User 3 was not invited to the room") else: - print("User 3 was invited to the room successfully after admin left") + logger.info("User 3 was invited to the room successfully after admin left") # Clean up if server_process is not None: From a34bcafa72c37b6de2b7bd8d963f1dde7ae11e5f Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Thu, 4 Sep 2025 14:48:22 +1000 Subject: [PATCH 3/4] refactor: extract common test logic to reduce duplication - Created _test_knock_with_code_admin_left_common() helper method - Eliminated ~90+ lines of duplicate code between SQLite and PostgreSQL tests - Improved maintainability while maintaining full test coverage - All tests passing with clean linting --- tests/test_e2e.py | 170 ++++++++++------------------------------------ 1 file changed, 34 insertions(+), 136 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 8357cd4..bd248be 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -509,21 +509,38 @@ async def test_e2e_knock_with_code_sqlite(self) -> None: shutil.rmtree(synapse_dir) raise e - async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: + async def _test_knock_with_code_admin_left_common( + self, + db: Literal["sqlite", "postgresql"] = "sqlite", + postgresql_url: Union[str, None] = None, + ) -> None: + """ + Common test logic for testing knock with code when an admin with high power level has left the room. + Tests that the system can still invite users through other remaining room members with sufficient power. + """ + postgres = None synapse_dir = None server_process = None stdout_thread = None stderr_thread = None + try: - # Create a temporary directory for the Synapse server access_code = "vldcde1" + + # Start database if needed + if db == "postgresql": + postgres, postgresql_url = await self.start_test_postgres() + + # Start Synapse server ( synapse_dir, config_path, server_process, stdout_thread, stderr_thread, - ) = await self.start_test_synapse() + ) = await self.start_test_synapse(db=db, postgresql_url=postgresql_url) + + # Register test users await self.register_user( config_path=config_path, dir=synapse_dir, @@ -546,7 +563,7 @@ async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: admin=True, ) - # Login to obtain access token of all users + # Login to obtain access tokens user_1_id, user_1_access_token = await self.login_user( user="test1", password="123123123" ) @@ -557,6 +574,7 @@ async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: user="test3", password="123123123" ) + # Create room and set up the scenario room_id = await self.create_private_room(user_1_access_token) # User 2 needs to be invited and then join the room first (required before they can leave) @@ -578,13 +596,14 @@ async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: # User 2 (with highest power level besides creator) leaves the room await self.leave_room(room_id=room_id, access_token=user_2_access_token) + # Set room to be knockable with access code await self.set_room_knockable_with_code( room_id=room_id, access_token=user_1_access_token, access_code=access_code, ) - # Invoke knock with code endpoint - should still work because user1 is still in the room + # Test the knock with code functionality - should still work because user1 is still in the room await self.knock_with_code(access_code, user_3_access_token) # Wait for the invite - should work because user1 is still available to invite @@ -598,17 +617,10 @@ async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: else: print("User 3 was invited to the room successfully after admin left") - # Clean up - if server_process is not None: - server_process.terminate() - server_process.wait() - if stdout_thread is not None: - stdout_thread.join() - if stderr_thread is not None: - stderr_thread.join() - if synapse_dir is not None: - shutil.rmtree(synapse_dir) - except Exception as e: + finally: + # Clean up resources + if postgres is not None: + postgres.stop() if server_process is not None: server_process.terminate() server_process.wait() @@ -618,7 +630,12 @@ async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: stderr_thread.join() if synapse_dir is not None: shutil.rmtree(synapse_dir) - raise e + + async def test_e2e_knock_with_code_admin_left_sqlite(self) -> None: + await self._test_knock_with_code_admin_left_common(db="sqlite") + + async def test_e2e_knock_with_code_admin_left_postgresql(self) -> None: + await self._test_knock_with_code_admin_left_common(db="postgresql") async def test_e2e_knock_with_code_postgresql(self) -> None: postgres = None @@ -712,125 +729,6 @@ async def test_e2e_knock_with_code_postgresql(self) -> None: shutil.rmtree(synapse_dir) raise e - async def test_e2e_knock_with_code_admin_left_postgresql(self) -> None: - postgres = None - server_process = None - stdout_thread = None - stderr_thread = None - synapse_dir = None - try: - # Create a temporary directory for the Synapse server - access_code = "vldcde1" - postgres, postgres_url = await self.start_test_postgres() - ( - synapse_dir, - config_path, - server_process, - stdout_thread, - stderr_thread, - ) = await self.start_test_synapse( - db="postgresql", postgresql_url=postgres_url - ) - await self.register_user( - config_path=config_path, - dir=synapse_dir, - user="test1", - password="123123123", - admin=True, - ) - await self.register_user( - config_path=config_path, - dir=synapse_dir, - user="test2", - password="123123123", - admin=True, - ) - await self.register_user( - config_path=config_path, - dir=synapse_dir, - user="test3", - password="123123123", - admin=True, - ) - - # Login to obtain access token of all users - user_1_id, user_1_access_token = await self.login_user( - user="test1", password="123123123" - ) - user_2_id, user_2_access_token = await self.login_user( - user="test2", password="123123123" - ) - user_3_id, user_3_access_token = await self.login_user( - user="test3", password="123123123" - ) - - room_id = await self.create_private_room(user_1_access_token) - - # User 2 needs to be invited and then join the room first (required before they can leave) - await self.invite_user_to_room( - room_id=room_id, user_id=user_2_id, access_token=user_1_access_token - ) - await self.join_room(room_id=room_id, access_token=user_2_access_token) - - # Set power levels: user1 = 100 (room creator), user2 = 100, user3 = 0 - await self.set_room_power_levels( - room_id=room_id, - access_token=user_1_access_token, - user_power_levels={ - user_1_id: 100, - user_2_id: 100, - }, - ) - - # User 2 (with highest power level besides creator) leaves the room - await self.leave_room(room_id=room_id, access_token=user_2_access_token) - - await self.set_room_knockable_with_code( - room_id=room_id, - access_token=user_1_access_token, - access_code=access_code, - ) - - # Invoke knock with code endpoint - should still work because user1 is still in the room - await self.knock_with_code(access_code, user_3_access_token) - - # Wait for the invite - should work because user1 is still available to invite - received_invitation = await self.wait_for_room_invitation( - room_id=room_id, - user_id=user_3_id, - access_token=user_1_access_token, - ) - if not received_invitation: - self.fail("User 3 was not invited to the room") - else: - print("User 3 was invited to the room successfully after admin left") - - # Clean up - if postgres is not None: - postgres.stop() - if server_process is not None: - server_process.terminate() - server_process.wait() - if stdout_thread is not None: - stdout_thread.join() - if stderr_thread is not None: - stderr_thread.join() - if synapse_dir is not None: - shutil.rmtree(synapse_dir) - except Exception as e: - if postgres is not None: - postgres.stop() - if server_process is not None: - server_process.terminate() - server_process.wait() - if stdout_thread is not None: - stdout_thread.join() - if stderr_thread is not None: - stderr_thread.join() - if synapse_dir is not None: - shutil.rmtree(synapse_dir) - raise e - async def get_access_token_without_access_code(self): get_access_token_url = ( "http://localhost:8008/_synapse/client/pangea/v1/request_room_code" From 97a13c00278921aaa818a6f8ce72ec2b177b81b0 Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Thu, 4 Sep 2025 15:18:03 +1000 Subject: [PATCH 4/4] fix: format long logger line to pass black code style check --- tests/test_e2e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 7003ff0..629fc01 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -615,7 +615,9 @@ async def _test_knock_with_code_admin_left_common( if not received_invitation: self.fail("User 3 was not invited to the room") else: - logger.info("User 3 was invited to the room successfully after admin left") + logger.info( + "User 3 was invited to the room successfully after admin left" + ) finally: # Clean up resources