From e20ab58f6857ef0ec55e1020bcf71be8676132a1 Mon Sep 17 00:00:00 2001 From: sgofferj Date: Tue, 25 Aug 2026 15:35:51 +0300 Subject: [PATCH 1/2] feat: version-aware User Account Management API for TAK Server 5.8 5.8 relocated the user-management endpoints from /user-management/api to /Marti/api/user-management/api. The wrapper now detects the base path from the server's version string on first use (cached), with an optional user_management_base override on the Server instance and a safe legacy fallback when detection fails. Adds HomeApi.is_ldap_admin() for the new-in-5.8 isLdapAdmin endpoint (returns False on 5.7 where it does not exist, live-guarded). 5 new unit tests covering detection/caching/fallback/override; live tests verify prefix detection on the real 5.7 server. --- CHANGELOG.md | 14 ++ live_tests/test_live_basic.py | 34 +++++ .../tak_file_user_account_management_api.py | 88 ++++++++--- src/python_takserver_api/tak_home_api.py | 14 ++ tests/test_user_management.py | 138 ++++++++++++++++++ 5 files changed, 269 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5145f33..2697290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ development state is tracked under `[Unreleased]`. ## [Unreleased] +### Changed + +- User Account Management API is now version-aware: TAK Server 5.8 + relocated the endpoints from `/user-management/api` to + `/Marti/api/user-management/api`. The base path is detected from the + server's version string on first use and cached; it can be forced by + setting `user_management_base` on the `Server` instance. Detection and + fallback covered by unit tests, verified live on 5.7. + +### Added + +- `HomeApi.is_ldap_admin()`: wraps the new-in-5.8 + `GET /Marti/api/util/isLdapAdmin`; returns `False` on servers where + the endpoint does not exist (live-guarded). ### Added - Submission API (`SubmissionApi`, `server.submission`): complete wrapper diff --git a/live_tests/test_live_basic.py b/live_tests/test_live_basic.py index 16e69c5..af958bd 100644 --- a/live_tests/test_live_basic.py +++ b/live_tests/test_live_basic.py @@ -192,3 +192,37 @@ async def test_user_api_group_lifecycle(server) -> None: # no user carries the group anymore -> it must be gone _, groups = await server.user.get_all_group_names() assert grp not in [g["groupname"] for g in groups] + + +@pytest.mark.asyncio +async def test_um_prefix_detection_on_live_server(server) -> None: + """The detected user-management prefix matches the running server.""" + version = await server.home.server_version() + prefix = await server.user._base() # noqa: SLF001 + if version and version.startswith("5.8"): + assert prefix == "/Marti/api/user-management/api" + else: + assert prefix == "/user-management/api" + # and the legacy paths still work on this server + status, _ = await server.user.get_all_users() + assert status == 200 + + +@pytest.mark.asyncio +async def test_is_ldap_admin_guard(server) -> None: + """isLdapAdmin is new in 5.8; on this 5.7 server it answers 404. + + The wrapper returns False in that case. When the server is upgraded, + this test starts failing - which is the signal to verify real + behaviour. + """ + version = await server.home.server_version() + result = await server.home.is_ldap_admin() + if version and not version.startswith("5.8"): + # endpoint must not exist pre-5.8 + status, _ = await server.connection.request( + "get", + f"{server.api_base_url}/Marti/api/util/isLdapAdmin", + headers={"Content-Type": "application/json"}, + ) + assert status == 404 diff --git a/src/python_takserver_api/tak_file_user_account_management_api.py b/src/python_takserver_api/tak_file_user_account_management_api.py index aeaa3c7..9e1e92c 100644 --- a/src/python_takserver_api/tak_file_user_account_management_api.py +++ b/src/python_takserver_api/tak_file_user_account_management_api.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# tak_file_user_account_management_api.py from https://github.com/sgofferj/takserver-api-python +# tak_file_user_account_management_api.py from https://github.com/sgofferj/python-takserver-api # # Copyright Stefan Gofferje # @@ -13,19 +13,61 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. # See the License for the specific language governing permissions and # limitations under the License. -# -"""file-user-account-management-api - https://docs.tak.gov/api/takserver#tag/file-user-account-management-api""" +"""file-user-account-management-api + +Manages file-based user accounts and groups. All operations are +version-aware: on TAK Server 5.7 the endpoints live under +``/user-management/api``, on 5.8+ they moved to +``/Marti/api/user-management/api``. The base path is detected from the +server's version string on first use (and cached); it can also be forced +by setting ``user_management_base`` on the ``Server`` instance before +first use. +""" +import re from typing import Any +PREFIX_LEGACY = "/user-management/api" +PREFIX_MARTI = "/Marti/api/user-management/api" + + +def um_prefix_for_version(version: str | None) -> str: + """Returns the user-management base path for a TAK version string. + + TAK Server 5.8 relocated the user-management endpoints from + ``/user-management/api`` to ``/Marti/api/user-management/api``. + Anything undetectable falls back to the legacy prefix. + """ + if not version: + return PREFIX_LEGACY + match = re.match(r"^v?(\d+)\.(\d+)", version.strip()) + if match: + major, minor = int(match.group(1)), int(match.group(2)) + if major > 5 or (major == 5 and minor >= 8): + return PREFIX_MARTI + return PREFIX_LEGACY + class UserAccountManagementApi: """User Account Management API wrapper""" def __init__(self, server: Any) -> None: self.server = server + # explicit override wins; otherwise detect once, lazily + self._prefix: str | None = getattr(server, "user_management_base", None) + + async def _base(self) -> str: + """Resolves (and caches) the user-management base path""" + if self._prefix is None: + s, r = await self.server.connection.request( + "get", + f"{self.server.api_base_url}/Marti/api/version", + headers={"Content-Type": "application/json"}, + ) + self._prefix = um_prefix_for_version(str(r) if s == 200 else None) + return self._prefix # pylint: disable=too-many-arguments, too-many-positional-arguments async def create_or_update_file_user( @@ -36,12 +78,16 @@ async def create_or_update_file_user( group_list_in: list[str] | None = None, group_list_out: list[str] | None = None, ) -> tuple[int, Any]: - """Create or update a user on the server + """Create or update a user on the server. The three group lists are always sent - the server replies with HTTP 500 when they are omitted. + + Note: on 5.7, creating a user with a group in `groupList` did not + reliably persist the membership; assigning groups afterwards via + `update_groups_for_user()` proved dependable. """ - path = "/user-management/api/new-user" + path = f"{await self._base()}/new-user" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} data: dict[str, Any] = { @@ -56,7 +102,7 @@ async def create_or_update_file_user( async def get_all_users(self) -> tuple[int, Any]: """Returns a list of all users on the server""" - path = "/user-management/api/list-users" + path = f"{await self._base()}/list-users" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} s, r = await self.server.connection.request("get", url, headers=headers) @@ -64,7 +110,7 @@ async def get_all_users(self) -> tuple[int, Any]: async def get_all_group_names(self) -> tuple[int, Any]: """Returns a list of all groups on the server""" - path = "/user-management/api/list-groupnames" + path = f"{await self._base()}/list-groupnames" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} s, r = await self.server.connection.request("get", url, headers=headers) @@ -83,33 +129,37 @@ async def group_exists(self, group: str) -> bool: return bool(g) async def get_users_in_group(self, group: str) -> tuple[int, Any]: - """Returns the users in a group (SimpleGroupWithUsersModel)""" - path = f"/user-management/api/users-in-group/{group}" + """Returns a group record with its member list""" + path = f"{await self._base()}/users-in-group/{group}" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} s, r = await self.server.connection.request("get", url, headers=headers) return s, r async def get_groups_for_user(self, username: str) -> tuple[int, Any]: - """Returns the groups a user belongs to (SimpleUserGroupModel)""" - path = f"/user-management/api/get-groups-for-user/{username}" + """Returns the group entitlements of a user (UserManagement model). + + For channel subscriptions see + `tak_group_api.GroupApi.get_groups_for_user()` instead. + """ + path = f"{await self._base()}/get-groups-for-user/{username}" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} s, r = await self.server.connection.request("get", url, headers=headers) return s, r async def change_user_password(self, username: str, password: str) -> tuple[int, Any]: - """Change a user's password""" - path = "/user-management/api/change-user-password" + """Changes a user's password""" + path = f"{await self._base()}/change-user-password" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} - data: dict[str, Any] = {"username": username, "password": password} + data = {"username": username, "password": password} s, r = await self.server.connection.request("put", url, headers=headers, json=data) return s, r async def delete_user(self, username: str) -> tuple[int, Any]: - """Delete a user from the server""" - path = f"/user-management/api/delete-user/{username}" + """Deletes a user account""" + path = f"{await self._base()}/delete-user/{username}" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} s, r = await self.server.connection.request("delete", url, headers=headers) @@ -134,7 +184,7 @@ async def create_file_users_in_bulk( rejects requests without ``group_list`` (HTTP 400). Returns a list of UserPasswordModel with the generated passwords. """ - path = "/user-management/api/new-users" + path = f"{await self._base()}/new-users" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} data: dict[str, Any] = { @@ -160,7 +210,7 @@ async def update_users_for_group( The three user lists are always sent - the server replies with HTTP 500 when any of them is omitted. """ - path = "/user-management/api/update-group-users" + path = f"{await self._base()}/update-group-users" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} data: dict[str, Any] = { @@ -184,7 +234,7 @@ async def update_groups_for_user( The three group lists are always sent - the server replies with HTTP 500 when any of them is omitted. """ - path = "/user-management/api/update-groups" + path = f"{await self._base()}/update-groups" url = self.server.api_base_url + path headers = {"Content-Type": "application/json"} data: dict[str, Any] = { diff --git a/src/python_takserver_api/tak_home_api.py b/src/python_takserver_api/tak_home_api.py index 9067eff..5796754 100644 --- a/src/python_takserver_api/tak_home_api.py +++ b/src/python_takserver_api/tak_home_api.py @@ -34,6 +34,20 @@ async def is_admin(self) -> bool: _, r = await self.server.connection.request("get", url, headers=headers) return bool(r) + async def is_ldap_admin(self) -> bool: + """Check if the configured certificate has LDAP admin rights. + + New in TAK Server 5.8 - on 5.7 the endpoint does not exist + (HTTP 404). + """ + path = "/Marti/api/util/isLdapAdmin" + url = self.server.api_base_url + path + headers = {"Content-Type": "application/json"} + s, r = await self.server.connection.request("get", url, headers=headers) + if s == 200: + return bool(r) + return False + async def get_home(self) -> tuple[int, Any]: """Returns the server's home payload""" path = "/Marti/api/home" diff --git a/tests/test_user_management.py b/tests/test_user_management.py index faede8d..9489156 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -31,6 +31,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -63,6 +64,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -95,6 +97,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -122,6 +125,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -148,6 +152,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -173,6 +178,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -197,6 +203,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -221,6 +228,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -247,6 +255,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -275,6 +284,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -307,6 +317,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -334,6 +345,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -369,6 +381,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -410,6 +423,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -444,6 +458,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -480,6 +495,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -513,6 +529,7 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) @@ -549,8 +566,129 @@ async def request( class MockServer: # noqa: N801 api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/user-management/api" connection: Any = MockConnection() api = UserAccountManagementApi(MockServer()) status, _ = await api.update_groups_for_user("op1") assert status == 200 + + +def test_um_prefix_for_version() -> None: + """5.8+ uses the Marti prefix, older/unknown versions the legacy one.""" + from python_takserver_api.tak_file_user_account_management_api import ( + PREFIX_LEGACY, + PREFIX_MARTI, + um_prefix_for_version, + ) + + assert um_prefix_for_version("5.7-RELEASE-43") == PREFIX_LEGACY + assert um_prefix_for_version("5.6-RELEASE-1") == PREFIX_LEGACY + assert um_prefix_for_version("5.8-RELEASE-66") == PREFIX_MARTI + assert um_prefix_for_version("5.9-RELEASE-1") == PREFIX_MARTI + assert um_prefix_for_version("6.0-RELEASE-1") == PREFIX_MARTI + assert um_prefix_for_version(None) == PREFIX_LEGACY + assert um_prefix_for_version("garbage") == PREFIX_LEGACY + + +@pytest.mark.asyncio +async def test_um_base_auto_detection_57() -> None: + """On a 5.7 server the legacy prefix is detected and cached.""" + from python_takserver_api.tak_file_user_account_management_api import ( + PREFIX_LEGACY, + UserAccountManagementApi, + ) + + class MockConnection: # noqa: N801 + async def request(self, method, url, headers=None, json=None, data=None): + if url.endswith("/Marti/api/version"): + return 200, "5.7-RELEASE-43 HEAD ..." + raise AssertionError(f"unexpected request {url}") + + class MockServer: # noqa: N801 + api_base_url: str = "https://tak.example.com:8443" + connection: Any = MockConnection() + + api = UserAccountManagementApi(MockServer()) + assert await api._base() == PREFIX_LEGACY # noqa: SLF001 + assert await api._base() == PREFIX_LEGACY # cached, no second probe + + +@pytest.mark.asyncio +async def test_um_base_auto_detection_58() -> None: + """On a 5.8 server the Marti prefix is detected.""" + from python_takserver_api.tak_file_user_account_management_api import ( + PREFIX_MARTI, + UserAccountManagementApi, + ) + + class MockConnection: # noqa: N801 + async def request(self, method, url, headers=None, json=None, data=None): + assert url.endswith("/Marti/api/version") + return 200, "5.8-RELEASE-66 RELEASE" + + class MockServer: # noqa: N801 + api_base_url: str = "https://tak.example.com:8443" + connection: Any = MockConnection() + + api = UserAccountManagementApi(MockServer()) + status_url_used = [] + + async def _spy_base(): + prefix = await api._base() + status_url_used.append(prefix) + return prefix + + assert await _spy_base() == PREFIX_MARTI + + # a full operation must use the detected prefix + async def handler(method, url, headers=None, json=None, data=None): + assert "/Marti/api/user-management/api/list-users" in url + return 200, [] + + api.server.connection = MockConnection() + api.server.connection.request = handler # type: ignore[method-assign] + status, users = await api.get_all_users() + assert status == 200 + + +@pytest.mark.asyncio +async def test_um_base_detection_failure_falls_back_to_legacy() -> None: + """If the version endpoint fails, the legacy prefix is used.""" + from python_takserver_api.tak_file_user_account_management_api import ( + PREFIX_LEGACY, + UserAccountManagementApi, + ) + + class MockConnection: # noqa: N801 + async def request(self, method, url, headers=None, json=None, data=None): + if url.endswith("/Marti/api/version"): + return 500, "boom" + raise AssertionError(f"unexpected request {url}") + + class MockServer: # noqa: N801 + api_base_url: str = "https://tak.example.com:8443" + connection: Any = MockConnection() + + api = UserAccountManagementApi(MockServer()) + assert await api._base() == PREFIX_LEGACY + + +@pytest.mark.asyncio +async def test_um_base_explicit_override_wins() -> None: + """A user_management_base attribute on the server overrides detection.""" + from python_takserver_api.tak_file_user_account_management_api import ( + UserAccountManagementApi, + ) + + class MockConnection: # noqa: N801 + async def request(self, method, url, headers=None, json=None, data=None): + raise AssertionError("no requests expected") + + class MockServer: # noqa: N801 + api_base_url: str = "https://tak.example.com:8443" + user_management_base: str = "/Marti/api/user-management/api" + connection: Any = MockConnection() + + api = UserAccountManagementApi(MockServer()) + assert await api._base() == "/Marti/api/user-management/api" From 30b007586b23d756e2a7ac0a588a65bf881ccbad Mon Sep 17 00:00:00 2001 From: sgofferj Date: Tue, 25 Aug 2026 15:41:59 +0300 Subject: [PATCH 2/2] docs: correct IN/OUT group direction semantics IN and OUT are from the group's point of view: group_list_in holds groups a user may send into (writer side), group_list_out groups whose traffic they receive (reader side). Fixes reversed readers/writers test fixtures and documents the semantics in all affected docstrings. --- .../tak_file_user_account_management_api.py | 14 ++++++++--- tests/test_user_management.py | 24 +++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/python_takserver_api/tak_file_user_account_management_api.py b/src/python_takserver_api/tak_file_user_account_management_api.py index 9e1e92c..ea77500 100644 --- a/src/python_takserver_api/tak_file_user_account_management_api.py +++ b/src/python_takserver_api/tak_file_user_account_management_api.py @@ -181,7 +181,9 @@ async def create_file_users_in_bulk( substituted with the running number from ``start_n`` to ``end_n``, e.g. ``"live-test-user-[N]"`` with ``start_n=1``, ``end_n=3`` creates ``live-test-user-1`` .. ``live-test-user-3``. The server - rejects requests without ``group_list`` (HTTP 400). + rejects requests without ``group_list`` (HTTP 400). Directions: + `group_list_in` = send-into memberships, `group_list_out` = + receive-from memberships. Returns a list of UserPasswordModel with the generated passwords. """ path = f"{await self._base()}/new-users" @@ -208,7 +210,10 @@ async def update_users_for_group( """Replace the user membership of a group (SimpleGroupWithUsersModel) The three user lists are always sent - the server replies with - HTTP 500 when any of them is omitted. + HTTP 500 when any of them is omitted. Directions are from the + group's point of view: `users_in_group_list_in` lists users who + may send INTO the group, `users_in_group_list_out` users who + receive its traffic, and `users_in_group_list` covers both. """ path = f"{await self._base()}/update-group-users" url = self.server.api_base_url + path @@ -232,7 +237,10 @@ async def update_groups_for_user( """Replace the group membership of a user (SimpleUserGroupModel) The three group lists are always sent - the server replies with - HTTP 500 when any of them is omitted. + HTTP 500 when any of them is omitted. Directions are from the + group's point of view: `group_list_in` = groups the user may send + into, `group_list_out` = groups whose traffic they receive, + `group_list` = both directions. """ path = f"{await self._base()}/update-groups" url = self.server.api_base_url + path diff --git a/tests/test_user_management.py b/tests/test_user_management.py index 9489156..28a9b3c 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -57,8 +57,8 @@ async def request( data: str | None = None, ) -> tuple[int, Any]: assert json is not None - assert json["groupListIN"] == ["readers"] - assert json["groupListOUT"] == ["writers"] + assert json["groupListIN"] == ["writers"] + assert json["groupListOUT"] == ["readers"] assert json["groupList"] == ["both"] return 201, {"username": "op2"} @@ -71,8 +71,8 @@ class MockServer: # noqa: N801 status, data = await api.create_or_update_file_user( username="op2", password="hunter2", # pragma: allowlist secret - group_list_in=["readers"], - group_list_out=["writers"], + group_list_in=["writers"], + group_list_out=["readers"], group_list_both=["both"], ) assert status == 201 @@ -374,8 +374,8 @@ async def request( "startN": 1, "endN": 3, "groupList": ["both"], - "groupListIN": ["readers"], - "groupListOUT": ["writers"], + "groupListIN": ["writers"], + "groupListOUT": ["readers"], } return 200, [{"username": "bulk-1", "password": "pw1"}] # pragma: allowlist secret @@ -390,8 +390,8 @@ class MockServer: # noqa: N801 start_n=1, end_n=3, group_list=["both"], - group_list_in=["readers"], - group_list_out=["writers"], + group_list_in=["writers"], + group_list_out=["readers"], ) assert status == 200 assert data[0]["username"] == "bulk-1" @@ -522,8 +522,8 @@ async def request( assert json == { "username": "op1", "groupList": ["both"], - "groupListIN": ["readers"], - "groupListOUT": ["writers"], + "groupListIN": ["writers"], + "groupListOUT": ["readers"], } return 200, None @@ -536,8 +536,8 @@ class MockServer: # noqa: N801 status, _ = await api.update_groups_for_user( username="op1", group_list=["both"], - group_list_in=["readers"], - group_list_out=["writers"], + group_list_in=["writers"], + group_list_out=["readers"], ) assert status == 200