From 8ebf9d933fe8845b26f6dc7c794e8e12cd5a026c Mon Sep 17 00:00:00 2001 From: michael-richey <41595765+michael-richey@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:05:27 -0400 Subject: [PATCH] Remove v1 user-creation path Use the v2 user-creation path for all new users and remove the legacy v1 implementation. Retain hidden parsing for the removed option and environment variable so existing configurations fail fast with migration guidance instead of silently changing behavior. --- README.md | 8 - datadog_sync/commands/shared/options.py | 5 +- datadog_sync/model/users.py | 82 +------- datadog_sync/utils/configuration.py | 9 +- tests/unit/conftest.py | 1 - tests/unit/test_users.py | 258 ++++-------------------- 6 files changed, 47 insertions(+), 316 deletions(-) diff --git a/README.md b/README.md index e2c8fb94..aeb6f888 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ Datadog cli tool to sync resources across organizations. - [Config file](#config-file) - [Cleanup flag](#cleanup-flag) - [Verify DDR Status Flag](#verify-ddr-status-flag) - - [Create users via the v1 API](#create-users-via-the-v1-api) - [State Files](#state-files) - [Supported resources](#supported-resources) - [Best practices](#best-practices) @@ -211,13 +210,6 @@ For example, `ResourceA` and `ResourceB` are imported and synced, followed by de By default all commands check the Datadog Disaster Recovery (DDR) status of both the source and destination organizations before running. This behavior is controlled by the boolean flag `--verify-ddr-status` or the environment variable `DD_VERIFY_DDR_STATUS`. -#### Create users via the v1 API - -The destination enforces user uniqueness on the `handle`, not the email — multiple users may share an email address. The v2 user create endpoint (`POST /api/v2/users`) does not accept a `handle` (it derives one from the email), so users that share an email collapse onto a single handle: the first create takes a handle that may belong to another user, and later creates for that email return an HTTP 409. - -Passing `--use-v1-user-api` (or setting `DD_USE_V1_USER_API=true`) makes `sync` create users via the v1 user endpoint (`POST /api/v1/user`), which accepts an explicit handle, so every user keeps its own handle and no create collides. The flag is off by default. - - #### Preserving a source handle during v2 user creation The v2 user create endpoint (`POST /api/v2/users`) normally derives the destination `handle` from the email. Some destination organizations support creating users with an explicit `handle` instead — for example, a non-email-shaped value such as `user_test`. diff --git a/datadog_sync/commands/shared/options.py b/datadog_sync/commands/shared/options.py index 4699de4d..b775205e 100644 --- a/datadog_sync/commands/shared/options.py +++ b/datadog_sync/commands/shared/options.py @@ -597,10 +597,7 @@ def click_config_file_provider(ctx: Context, opts: CustomOptionClass, value: Non envvar=constants.DD_USE_V1_USER_API, type=bool, default=False, - show_default=True, - help="Create users via the v1 API (/api/v1/user) instead of v2. v2 cannot set a " - "handle (it derives one from the email), which collides when users share an email; " - "v1 accepts an explicit handle so each user keeps its own. Off by default.", + hidden=True, cls=CustomOptionClass, ), option( diff --git a/datadog_sync/model/users.py b/datadog_sync/model/users.py index e4c2a8f9..9e6a4a96 100644 --- a/datadog_sync/model/users.py +++ b/datadog_sync/model/users.py @@ -4,7 +4,6 @@ # Copyright 2019 Datadog, Inc. from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, Any, Optional, List, Dict, Tuple, cast from datadog_sync.utils.base_resource import BaseResource, ResourceConfig @@ -43,12 +42,11 @@ class Users(BaseResource): "attributes.verified", "attributes.service_account", # NOTE: attributes.handle is deliberately NOT excluded here. It is the - # user mapping key (resource_mapping_key below) and the payload for the - # v1 user creation, so it must survive prep_resource. It is popped - # manually before the v2 PATCH (v2 treats handle as read-only there) and - # before the v2 POST unless --preserve-user-handle opts into passing it - # through, and kept out of update diffs via - # deep_diff_config.exclude_regex_paths below. + # user mapping key (resource_mapping_key below), so it must survive + # prep_resource. It is popped manually before the v2 PATCH (v2 treats + # handle as read-only there) and before the v2 POST unless + # --preserve-user-handle opts into passing it through, and kept out of + # update diffs via deep_diff_config.exclude_regex_paths below. "attributes.icon", "attributes.modified_at", "attributes.mfa_enabled", @@ -69,7 +67,6 @@ class Users(BaseResource): page_size=500, ) roles_path: str = "/api/v2/roles/{}/users" - user_lookup_retry_delays: Tuple[float, ...] = (1.0, 2.0) async def get_resources(self, client: CustomClient) -> List[Dict]: resp = await client.paginated_request(client.get)( @@ -104,25 +101,6 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: return await self.update_resource(_id, resource) attributes = resource["attributes"] - if self.config.use_v1_user_api: - # v2 create cannot set a handle (it derives one from the email), which - # collapses distinct-handle users that share an email onto one handle. - # v1 accepts an explicit handle, so each user keeps its own. - try: - user = await self._create_via_v1( - attributes.get("handle"), - attributes.get("name"), - attributes.get("email"), - resource.get("relationships", {}).get("roles", {}).get("data", []), - ) - except UserRoleAssignmentError as e: - # Preserve the reconciled UUID and successful memberships so - # downstream resources can still resolve this user. Re-raising - # makes the apply handler count and emit the partial failure. - self.config.state.destination[self.resource_type][_id] = e.user - raise - return _id, user - destination_client = self.config.destination_client # handle is normally read-only in v2 (derived from email) and must not be # sent, unless --preserve-user-handle opts into passing a non-empty value @@ -138,35 +116,6 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: resp = await destination_client.post(self.resource_config.base_path, {"data": resource}) return _id, resp["data"] - async def _create_via_v1( - self, - handle: Optional[str], - name: Optional[str], - email: Optional[str], - desired_roles: Optional[List[Dict]] = None, - ) -> Dict: - """Create the user via the v1 API, which accepts an explicit handle. - - v2 ``POST /api/v2/users`` cannot set a handle (it is derived from the - email), so users that share an email collapse onto one handle and later - creates 409 — and the v2 "winner" is created with the wrong handle. v1 - ``POST /api/v1/user`` accepts a distinct handle. The v1 response is the - legacy user shape, so re-resolve the v2 UUID by handle and return that - record — state and downstream references (roles, team_memberships) key - on the v2 UUID. - """ - if not handle: - raise ValueError("v1 user creation requires a handle") - - destination_client = self.config.destination_client - await destination_client.post("/api/v1/user", {"handle": handle, "name": name, "email": email}) - - user = await self._get_destination_user_by_handle(handle) - if user is None: - raise ValueError("v1-created user not found by handle after create") - await self._assign_missing_roles(user, desired_roles or []) - return user - async def _assign_missing_roles(self, user: Dict, desired_roles: List[Dict]) -> None: """Assign missing roles and keep the reconciled user state accurate.""" existing_roles = user.setdefault("relationships", {}).setdefault("roles", {}).setdefault("data", []) @@ -204,27 +153,6 @@ def _merge_role_state(updated_user: Dict, role_state: Dict) -> Dict: updated_role_ids.add(role_id) return updated_user - async def _get_destination_user_by_handle(self, handle: str) -> Optional[Dict]: - """Return the destination user whose handle matches exactly, or None. - - Transient HTTP errors are already retried by the client's - ``request_with_retry``; this adds a bounded re-query loop with delays to - absorb read-after-write visibility lag after a v1 create. - """ - destination_client = self.config.destination_client - for attempt in range(len(self.user_lookup_retry_delays) + 1): - resp = await destination_client.paginated_request(destination_client.get)( - self.resource_config.base_path, - pagination_config=self.pagination_config, - params={"filter": handle}, - ) - for user in resp: - if user.get("attributes", {}).get("handle") == handle: - return user - if attempt < len(self.user_lookup_retry_delays): - await asyncio.sleep(self.user_lookup_retry_delays[attempt]) - return None - async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client diff --git a/datadog_sync/utils/configuration.py b/datadog_sync/utils/configuration.py index af5862ff..2c139382 100644 --- a/datadog_sync/utils/configuration.py +++ b/datadog_sync/utils/configuration.py @@ -109,7 +109,6 @@ class Configuration(object): max_workers_per_type: Dict[str, int] = field(default_factory=dict) command: str = "" allow_partial_permissions_roles: List[str] = field(default_factory=list) - use_v1_user_api: bool = False preserve_user_handle: bool = False resources: Dict[str, BaseResource] = field(default_factory=dict) resources_arg: List[str] = field(default_factory=list) @@ -437,7 +436,12 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration: max_workers_per_type = _parse_max_workers_per_type(max_workers_per_type_raw, known_resource_types) create_global_downtime = kwargs.get("create_global_downtime") validate = kwargs.get("validate") - use_v1_user_api = kwargs.get("use_v1_user_api") or False + if kwargs.get("use_v1_user_api"): + raise click.UsageError( + "--use-v1-user-api and DD_USE_V1_USER_API are no longer supported. " + "Use --preserve-user-handle when the destination supports explicit " + "handles during user creation." + ) preserve_user_handle = kwargs.get("preserve_user_handle") or False verify_ddr_status = kwargs.get("verify_ddr_status") backup_before_reset = not kwargs.get("do_not_backup") @@ -695,7 +699,6 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration: emit_json=emit_json, command=cmd.value, allow_partial_permissions_roles=allow_partial_permissions_roles, - use_v1_user_api=use_v1_user_api, preserve_user_handle=preserve_user_handle, id_payload=id_payload, max_concurrent_reads=max_concurrent_reads, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 86eff6e7..dc8a4059 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -25,5 +25,4 @@ def mock_config(): config.logger = MagicMock() # Explicit default so no test is affected by a stray truthy MagicMock attribute. config.preserve_user_handle = False - config.use_v1_user_api = False # ditto, added for consistency while touching this fixture return config diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index 3236f850..3f0215c0 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -3,24 +3,26 @@ # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2019 Datadog, Inc. -"""Tests for handle-keyed users + the --use-v1-user-api flag. +"""Tests for handle-keyed users and v2 user creation, including the opt-in +--preserve-user-handle flag. The Datadog destination enforces user uniqueness on the ``handle``, not the ``email`` — multiple users may share an email. Keying ``_existing_resources_map`` by email collapsed distinct-handle users onto one derived handle and caused a 409 Conflict on the second create. These tests cover switching the user mapping -key to the exact-case handle (PR1) and wiring the opt-in --use-v1-user-api flag. +key to the exact-case handle. Tests ``a``/``a2``/``a3``/``f`` are red against ``resource_mapping_key= "attributes.email"`` and green after the switch to ``"attributes.handle"`` plus the manual handle pop before the v2 POST. Test ``g`` is a green/green guard that handle stays excluded from update diffs across the excluded_attributes -> -exclude_regex_paths migration. ``p1``/``p2``/``p3`` guard the flag wiring. +exclude_regex_paths migration. """ import asyncio -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock +import click import pytest from click.testing import CliRunner @@ -120,7 +122,6 @@ def test_source_matched_by_handle_not_email(self, mock_config): class TestV2CreatePayload: def test_v2_post_body_excludes_handle_and_disabled(self, mock_config): """f: the v2 create body must not carry read-only handle or disabled.""" - mock_config.use_v1_user_api = False instance = Users(mock_config) instance._existing_resources_map = {} mock_config.destination_client.post = AsyncMock(return_value={"data": {"id": "dest-x", "attributes": {}}}) @@ -250,26 +251,30 @@ def test_handle_excluded_from_update_diff(self): assert not check_diff(Users.resource_config, dest, src) -class TestV1UserApiFlagWiring: - def test_build_config_flag_true(self, tmp_path): - """p1: --use-v1-user-api flows from kwargs into Configuration.""" - cfg = build_config(Command.IMPORT, use_v1_user_api=True, **_base_kwargs(tmp_path)) - assert cfg.use_v1_user_api is True +class TestRemovedV1UserApiSetting: + def test_build_config_rejects_removed_setting(self, tmp_path): + with pytest.raises(click.UsageError, match="--preserve-user-handle"): + build_config(Command.IMPORT, use_v1_user_api=True, **_base_kwargs(tmp_path)) - def test_build_config_flag_default_false(self, tmp_path): - """p1: absent flag defaults to False (guards a typo'd kwarg key).""" - cfg = build_config(Command.IMPORT, **_base_kwargs(tmp_path)) - assert cfg.use_v1_user_api is False + def test_sync_rejects_removed_cli_option(self): + result = CliRunner(mix_stderr=False).invoke( + cli, + ["sync", "--use-v1-user-api=true", "--validate=false"], + ) - def test_sync_accepts_v1_flag(self): - """p2: the sync command recognizes the flag (exit 2 == usage error).""" - result = CliRunner(mix_stderr=False).invoke(cli, ["sync", "--use-v1-user-api=true", "--validate=false"]) - assert result.exit_code != 2 + assert result.exit_code == 2 + assert "--preserve-user-handle" in result.stderr - def test_migrate_accepts_v1_flag(self): - """p3: migrate reuses @sync_options, so it recognizes the flag too.""" - result = CliRunner(mix_stderr=False).invoke(cli, ["migrate", "--use-v1-user-api=true", "--validate=false"]) - assert result.exit_code != 2 + def test_sync_rejects_removed_environment_variable(self): + result = CliRunner(mix_stderr=False).invoke( + cli, + ["sync", "--validate=false"], + env={"DD_USE_V1_USER_API": "true"}, + ) + + assert result.exit_code == 2 + assert "DD_USE_V1_USER_API" in result.stderr + assert "--preserve-user-handle" in result.stderr class TestPreserveUserHandleFlagWiring: @@ -293,16 +298,15 @@ def test_migrate_accepts_preserve_user_handle_flag(self): result = CliRunner(mix_stderr=False).invoke(cli, ["migrate", "--preserve-user-handle=true", "--validate=false"]) assert result.exit_code != 2 - def test_sync_help_shows_both_user_api_flags(self): - """new option is wired without disturbing the existing v1 flag.""" + def test_sync_help_shows_preserve_user_handle_flag(self): result = CliRunner(mix_stderr=False).invoke(cli, ["sync", "--help"]) - assert "--use-v1-user-api" in result.output assert "--preserve-user-handle" in result.output + assert "--use-v1-user-api" not in result.output - def test_migrate_help_shows_both_user_api_flags(self): + def test_migrate_help_shows_preserve_user_handle_flag(self): result = CliRunner(mix_stderr=False).invoke(cli, ["migrate", "--help"]) - assert "--use-v1-user-api" in result.output assert "--preserve-user-handle" in result.output + assert "--use-v1-user-api" not in result.output class TestPreserveUserHandleErrorIsNonFatal: @@ -345,22 +349,13 @@ def test_flag_true_nominal_create_succeeds(self, mock_config): assert resp["id"] == "dest-x" -def _mock_paginated(config, pages): - """Make destination_client.paginated_request(get)(...) yield ``pages`` in order.""" - inner = AsyncMock(side_effect=pages) - config.destination_client.paginated_request = MagicMock(return_value=inner) - return inner - - def _post_paths(mock_config): return [c.args[0] for c in mock_config.destination_client.post.call_args_list] class TestV2CreatePath: - def test_flag_off_uses_v2_not_v1(self, mock_config): - """b: with the flag off, create goes through the v2 endpoint and never - touches the v1 API (preserves the default upstream behavior).""" - mock_config.use_v1_user_api = False + def test_create_uses_v2_endpoint(self, mock_config): + """b: create always goes through the v2 endpoint.""" instance = Users(mock_config) instance._existing_resources_map = {} mock_config.destination_client.post = AsyncMock(return_value={"data": {"id": "dest-x", "attributes": {}}}) @@ -370,188 +365,6 @@ def test_flag_off_uses_v2_not_v1(self, mock_config): assert _post_paths(mock_config) == ["/api/v2/users"] -class TestV1CreatePath: - def test_flag_on_uses_v1_with_handle_not_v2(self, mock_config): - """d1: with the flag on, create posts to /api/v1/user carrying the handle, - never calls the v2 users endpoint, and returns the reconciled v2 UUID.""" - mock_config.use_v1_user_api = True - instance = Users(mock_config) - instance._existing_resources_map = {} - dest_user = _make_user("user-a@example.com", "shared@example.com", "dest-uuid-a") - mock_config.destination_client.post = AsyncMock(return_value={"data": {}}) - _mock_paginated(mock_config, [[dest_user]]) - - _id, r = asyncio.run( - instance.create_resource("src-a", _make_user("user-a@example.com", "shared@example.com", "src-a")) - ) - - v1_calls = [c for c in mock_config.destination_client.post.call_args_list if c.args[0] == "/api/v1/user"] - assert len(v1_calls) == 1 - assert v1_calls[0].args[1]["handle"] == "user-a@example.com" - assert "/api/v2/users" not in _post_paths(mock_config) - assert r["id"] == "dest-uuid-a" - - def test_shared_email_distinct_handles_both_created_via_v1(self, mock_config): - """The core fix. Two users share an email but have distinct handles: one - handle equals the shared email, the other differs. Under v2 the first - create derives its handle from the email and steals the second user's - handle, so the second 409s and can never be created. Via v1 each user is - created with its OWN handle, so both succeed.""" - mock_config.use_v1_user_api = True - instance = Users(mock_config) - instance._existing_resources_map = {} - dest_a = _make_user("abc@example.com", "jsmith@example.com", "dest-a") - dest_b = _make_user("jsmith@example.com", "jsmith@example.com", "dest-b") - mock_config.destination_client.post = AsyncMock(return_value={"data": {}}) - _mock_paginated(mock_config, [[dest_a], [dest_b]]) - - asyncio.run(instance.create_resource("src-a", _make_user("abc@example.com", "jsmith@example.com", "src-a"))) - asyncio.run(instance.create_resource("src-b", _make_user("jsmith@example.com", "jsmith@example.com", "src-b"))) - - v1_handles = [ - c.args[1]["handle"] - for c in mock_config.destination_client.post.call_args_list - if c.args[0] == "/api/v1/user" - ] - assert v1_handles == ["abc@example.com", "jsmith@example.com"] - assert "/api/v2/users" not in _post_paths(mock_config) - - def test_assigns_only_missing_roles_and_returns_updated_state(self, mock_config): - """v1 create assigns mapped roles after resolving the v2 UUID. - - Roles already present on the reconciled user are not posted again, and - successful assignments are reflected in the destination state returned - by create_resource. - """ - mock_config.use_v1_user_api = True - instance = Users(mock_config) - instance._existing_resources_map = {} - existing_role = {"id": "role-dst-existing", "type": "roles"} - missing_role = {"id": "role-dst-missing", "type": "roles"} - dest_user = _make_user( - "user-a@example.com", - "shared@example.com", - "dest-uuid-a", - roles=[existing_role], - ) - source_user = _make_user( - "user-a@example.com", - "shared@example.com", - "src-a", - roles=[existing_role, missing_role], - ) - mock_config.destination_client.post = AsyncMock(return_value={"data": {}}) - _mock_paginated(mock_config, [[dest_user]]) - - _, created = asyncio.run(instance.create_resource("src-a", source_user)) - - assert _post_paths(mock_config) == [ - "/api/v1/user", - "/api/v2/roles/role-dst-missing/users", - ] - assert created["relationships"]["roles"]["data"] == [existing_role, missing_role] - - def test_role_failure_persists_partial_state_and_reports_failure(self, mock_config): - """A failed role assignment is reported after later roles are attempted. - - Only successful assignments are recorded in returned destination state, - leaving failed roles eligible for retry on a later sync. The exception - lets the apply handler count the otherwise-partial create as a failure. - """ - mock_config.use_v1_user_api = True - instance = Users(mock_config) - instance._existing_resources_map = {} - failed_role = {"id": "role-dst-failed", "type": "roles"} - successful_role = {"id": "role-dst-success", "type": "roles"} - dest_user = _make_user("user-a@example.com", "shared@example.com", "dest-uuid-a") - source_user = _make_user( - "user-a@example.com", - "shared@example.com", - "src-a", - roles=[failed_role, successful_role], - ) - - async def post(path, _body): - if path == "/api/v2/roles/role-dst-failed/users": - raise _http_error(403) - return {"data": {}} - - mock_config.destination_client.post = AsyncMock(side_effect=post) - _mock_paginated(mock_config, [[dest_user]]) - - with pytest.raises( - UserRoleAssignmentError, match="1 role assignment failed while reconciling user" - ) as exc_info: - asyncio.run(instance.create_resource("src-a", source_user)) - - assert exc_info.value.failed_role_ids == ("role-dst-failed",) - assert _post_paths(mock_config) == [ - "/api/v1/user", - "/api/v2/roles/role-dst-failed/users", - "/api/v2/roles/role-dst-success/users", - ] - created = mock_config.state.destination["users"]["src-a"] - assert created["relationships"]["roles"]["data"] == [successful_role] - mock_config.logger.error.assert_called_once() - - def test_requires_handle(self, mock_config): - """d0: v1 creation raises when there is no handle to send.""" - mock_config.use_v1_user_api = True - instance = Users(mock_config) - with pytest.raises(ValueError): - asyncio.run(instance._create_via_v1("", "Person Name", "e@example.com")) - - def test_v1_post_failure_reraises(self, mock_config): - """d4: a v1 POST failure propagates so the apply loop counts it failed - and continues (DR-safe).""" - mock_config.use_v1_user_api = True - instance = Users(mock_config) - instance._existing_resources_map = {} - mock_config.destination_client.post = AsyncMock(side_effect=_http_error(400)) - with pytest.raises(CustomClientHTTPError): - asyncio.run( - instance.create_resource("src-a", _make_user("user-a@example.com", "shared@example.com", "src-a")) - ) - - -class TestReconcileByHandle: - def test_no_exact_match_raises(self, mock_config): - """If no exact-handle match is ever found after the v1 create, it raises.""" - mock_config.use_v1_user_api = True - instance = Users(mock_config) - instance._existing_resources_map = {} - mock_config.destination_client.post = AsyncMock(return_value={"data": {}}) - other = _make_user("user-b@example.com", "shared@example.com", "dest-b") - _mock_paginated(mock_config, [[other], [other], [other]]) - with patch("datadog_sync.model.users.asyncio.sleep", new_callable=AsyncMock) as sleep: - with pytest.raises(ValueError): - asyncio.run( - instance.create_resource("src-a", _make_user("user-a@example.com", "shared@example.com", "src-a")) - ) - assert sleep.await_args_list == [call(1.0), call(2.0)] - - def test_requeries_on_empty_then_matches(self, mock_config): - """d3b: read-after-write — an empty first page then a match re-queries - after a bounded delay rather than immediately retrying.""" - instance = Users(mock_config) - match = _make_user("user-a@example.com", "shared@example.com", "dest-uuid-a") - inner = _mock_paginated(mock_config, [[], [match]]) - with patch("datadog_sync.model.users.asyncio.sleep", new_callable=AsyncMock) as sleep: - user = asyncio.run(instance._get_destination_user_by_handle("user-a@example.com")) - assert user["id"] == "dest-uuid-a" - assert inner.call_count == 2 - assert sleep.await_args_list == [call(1.0)] - - def test_selects_exact_case_handle_among_candidates(self, mock_config): - """d5: with multiple filter candidates, only the exact-case handle wins.""" - instance = Users(mock_config) - a = _make_user("user-a@example.com", "shared@example.com", "dest-a") - b = _make_user("user-b@example.com", "shared@example.com", "dest-b") - _mock_paginated(mock_config, [[b, a]]) - user = asyncio.run(instance._get_destination_user_by_handle("user-a@example.com")) - assert user["id"] == "dest-a" - - class TestUpdatePathRegression: def test_successful_role_retry_remains_in_persisted_state(self, mock_config): """A successful role retry is not lost when PATCH omits relationships.""" @@ -630,9 +443,8 @@ def test_role_retry_persists_partial_state_and_reports_failure(self, mock_config assert stored["relationships"]["roles"]["data"] == [successful_role] def test_existing_handle_takes_update_path_no_create(self, mock_config): - """e: an existing destination handle routes to the update path — no v1 or - v2 create, no duplicate.""" - mock_config.use_v1_user_api = True + """e: an existing destination handle routes to the update path — no + create, no duplicate.""" instance = Users(mock_config) dest_user = _make_user("user-a@example.com", "shared@example.com", "dest-a") instance._existing_resources_map = {"user-a@example.com": dest_user}