From facd1fed862a2994d9929229c49b77820f506792 Mon Sep 17 00:00:00 2001 From: Umit Kit Date: Mon, 28 Sep 2026 12:25:22 -0400 Subject: [PATCH] users.previous_accounts and users.merge Someone deleted who signs in again gets a fresh account (a new id). users.previous_accounts(user_id) lists the person's earlier, deleted accounts in the app, and users.merge(user_id, from_id) brings one into the live account: roles, resource roles, passkeys, MFA methods and unused backup codes move over, the live account wins on meta and attributes, history stays on the old account. A second merge raises APIError (422, already_merged). Unit tests against the fake API; README entry. Live tests: a test identity signed in, given a role and deleted signs in again to a new id (the old one is 404), previous_accounts lists the old account, merge moves the role and a second merge is already_merged; someone deactivated and then deleted gets 403 account_deactivated at sign-in. 55 passed against scute-api-v2 v24. --- README.md | 11 ++++++- src/scute/users.py | 22 +++++++++++++ tests/conftest.py | 10 ++++++ tests/live/test_05_users.py | 64 +++++++++++++++++++++++++++++++++++-- tests/test_api.py | 17 ++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 592afee..3dd58f4 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,21 @@ scute.users.find_by_identifier("ada@example.com") # exact email (any case) or p scute.users.update(user_id, user_meta={"plan": "team"}) scute.users.deactivate(user_id) +scute.users.previous_accounts(user_id) # earlier, deleted accounts of the same person +scute.users.merge(user_id, from_id) # move one's roles, passkeys and MFA over (once) + scute.sessions.list(user_id) # the secret key alone is enough, no user session scute.sessions.revoke(user_id, session_id) # ends it at once scute.sessions.current_user(access_token) scute.sessions.sign_out(access_token) ``` +Someone deleted who signs in again gets a fresh account (a new id); their +earlier account stays deleted, with its history. `previous_accounts` lists the +earlier accounts and `merge` brings one into the live account (for meta and +attributes the live account wins). Someone deactivated and then deleted can't +sign in again (403, `account_deactivated`) until you bring them back. + `meta` and `user_meta` keys are the user meta fields declared for the app (in the dashboard): others are left out on create (listed in `user_meta_errors`) and refused on update. @@ -177,5 +186,5 @@ unexpected pass to clean up. Each run is named `live-` (users, roles, resources, agents, properties) and deletes what it made at the end, also when tests fail. It -keeps sign-ins to five per run (the API throttles them) and never prints the +keeps sign-ins to seven per run (the API throttles them) and never prints the secret, tokens or codes other than 424242. diff --git a/src/scute/users.py b/src/scute/users.py index 231009e..98f7022 100644 --- a/src/scute/users.py +++ b/src/scute/users.py @@ -79,6 +79,28 @@ def deactivate(self, user_id: str) -> Any: def delete(self, user_id: str) -> Any: return self._c.request("DELETE", self._c.app_path(f"/users/{self._c.esc(user_id)}")) + # ── Previous accounts ── + # Someone deleted who signs in again gets a fresh account (a new id). Their + # earlier accounts stay deleted, and can be merged into the live one. + + def previous_accounts(self, user_id: str) -> builtins.list[dict[str, Any]]: + """The person's earlier, deleted accounts in this app, newest first: id, + status, created_at, deleted_at, merged_into (once merged), and what each + still holds (roles and passkeys counted, mfa_methods listed).""" + data = self._c.request("GET", self._c.app_path(f"/users/{self._c.esc(user_id)}/previous_accounts")) + return list((data or {}).get("previous_accounts") or []) + + def merge(self, user_id: str, from_id: str) -> dict[str, Any]: + """Merge a previous (deleted) account of the same person into this live + one. Roles, resource roles, passkeys, MFA methods and unused backup codes + move over; meta and attributes are combined, the live account winning; + history stays on the old account. Answers {user_id, merged, moved: {roles, + resource_roles, passkeys, mfa_methods, backup_codes}} (counts moved). + + Once per account: merging it again raises APIError (422, code + "already_merged").""" + return dict(self._c.request("POST", self._c.app_path(f"/users/{self._c.esc(user_id)}/merge"), {"from": from_id})) + # ── Signing in as a user (support access) ── # Off until the app turns it on. The session is short, never refreshed, # and its token names who is really acting (Session.actor). diff --git a/tests/conftest.py b/tests/conftest.py index d07cc5a..190783d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,7 @@ class FakeScute: {"id": "user6", "email": "zoe@example.com", "phone": None}, ]) revoked: set[str] = field(default_factory=set) + merged: set[str] = field(default_factory=set) seen: list[httpx.Request] = field(default_factory=list) def paths(self, path: str) -> list[httpx.Request]: @@ -91,6 +92,15 @@ def ok(data: Any, status: int = 200) -> httpx.Response: return ok({"users": found, "current_page": page, "next_page": page + 1 if more else None, "per_page": size}) if path == "/v1/auth/app1/users" and method == "POST": return ok({"user": {"id": "user2", "identifier": body["identifier"]}}, 201) + if path == "/v1/app1/users/user1/previous_accounts" and method == "GET": + return ok({"previous_accounts": [{"id": "old1", "status": "active", "deleted_at": "2026-09-01T00:00:00Z", + "roles": 1, "passkeys": 0, "mfa_methods": ["totp"]}]}) + if path == "/v1/app1/users/user1/merge" and method == "POST": + if body["from"] in self.merged: + return ok({"error": "That account was already merged", "error_code": "already_merged"}, 422) + self.merged.add(body["from"]) + return ok({"user_id": "user1", "merged": body["from"], + "moved": {"roles": 1, "resource_roles": 0, "passkeys": 0, "mfa_methods": 1, "backup_codes": 0}}) if path.startswith("/v1/app1/users/user1"): if path.endswith("/sessions"): return ok([{"id": "ses1"}]) diff --git a/tests/live/test_05_users.py b/tests/live/test_05_users.py index bbda9c6..8c72906 100644 --- a/tests/live/test_05_users.py +++ b/tests/live/test_05_users.py @@ -1,5 +1,7 @@ """5. Admin users with the secret key: create, get (by id and by identifier), -update, deactivate, activate, delete. All SDK (scute.users). +update, deactivate, activate, delete; and after a delete, the fresh account a +returning user gets, their previous accounts and merging one in. All SDK +(scute.users); the sign-ins go through the HTTP helper. Not run: users.invite sends a real magic-link email (test identities only cover OTP codes). @@ -11,7 +13,16 @@ from scute import APIError, Scute -from .support import Cleanup, Names, delete_user_later +from .support import ( + Cleanup, + LiveAPI, + Names, + Policy, + assign_role, + delete_user_later, + revoke_role_later, + sign_in, +) pytestmark = pytest.mark.live @@ -86,3 +97,52 @@ def test_meta_needs_declared_fields(scute: Scute, names: Names, cleanup: Cleanup with pytest.raises(APIError) as refused: scute.users.update(user_id, user_meta={key: "team"}) assert refused.value.status == 422 + + +def test_a_deleted_user_who_signs_in_again_gets_a_fresh_account(api: LiveAPI, scute: Scute, names: Names, + cleanup: Cleanup, policy: Policy) -> None: + """Deleted, then signed in again: a new account, the old one listed as a + previous account and merged in (its role moves over), once.""" + email = names.email(11) + first = sign_in(api, email) + delete_user_later(scute, cleanup, first.user_id) + assign_role(api, cleanup, first.user_id, policy.auditor) + scute.users.delete(first.user_id) + + again = sign_in(api, email) + delete_user_later(scute, cleanup, again.user_id) + revoke_role_later(api, cleanup, again.user_id, policy.auditor) # the role moves over below + assert again.user_id != first.user_id + with pytest.raises(APIError) as gone: + scute.users.get(first.user_id) + assert gone.value.status == 404 + target = f"{policy.invoice}:1" + assert not scute.authz.check(user_id=again.user_id, action="read", resource=target).allowed + + previous = scute.users.previous_accounts(again.user_id) + assert [a["id"] for a in previous] == [first.user_id] + assert previous[0]["deleted_at"] and previous[0]["roles"] == 1 and "merged_into" not in previous[0] + + merged = scute.users.merge(again.user_id, first.user_id) + assert (merged["user_id"], merged["merged"], merged["moved"]["roles"]) == (again.user_id, first.user_id, 1) + held = api.ok("GET", api.apps(f"/authz/users/{again.user_id}/roles"))["roles"] + assert [g["role"] for g in held] == [policy.auditor] + assert scute.authz.check(user_id=again.user_id, action="read", resource=target).allowed + assert scute.users.previous_accounts(again.user_id)[0]["merged_into"] == again.user_id + + with pytest.raises(APIError) as twice: + scute.users.merge(again.user_id, first.user_id) + assert (twice.value.status, twice.value.code) == (422, "already_merged") + + +def test_someone_deactivated_then_deleted_cant_sign_in_again(api: LiveAPI, scute: Scute, names: Names, + cleanup: Cleanup) -> None: + """Deprovisioned (deactivated, then deleted): no fresh account, the sign-in is refused.""" + email = names.email(12) + user_id = str(scute.users.create(email)["user"]["id"]) + delete_user_later(scute, cleanup, user_id) + scute.users.deactivate(user_id) + scute.users.delete(user_id) + refused = api.call("POST", api.auth("/otps/login"), {"identifier": email}, secret=False) + assert (refused.status, refused.error_code) == (403, "account_deactivated") + assert scute.users.find_by_identifier(email) is None diff --git a/tests/test_api.py b/tests/test_api.py index 24cd1a5..366bbdb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import httpx import pytest @@ -49,6 +51,21 @@ def test_the_search_stops_after_a_bounded_number_of_pages(fake: FakeScute) -> No assert len(fake.seen) == client(fake).users.FIND_MAX_PAGES +def test_lists_and_merges_previous_accounts(fake: FakeScute) -> None: + c = client(fake) + previous = c.users.previous_accounts("user1") + assert [(a["id"], a["roles"], a["mfa_methods"]) for a in previous] == [("old1", 1, ["totp"])] + merged = c.users.merge("user1", "old1") + assert (merged["merged"], merged["moved"]["roles"]) == ("old1", 1) + assert (fake.seen[-1].method, fake.seen[-1].url.path) == ("POST", "/v1/app1/users/user1/merge") + assert json.loads(fake.seen[-1].content) == {"from": "old1"} + assert fake.seen[-1].headers["authorization"] == "Bearer sk_test" + with pytest.raises(APIError) as again: + c.users.merge("user1", "old1") + assert (again.value.status, again.value.code) == (422, "already_merged") + assert len(fake.paths("/v1/app1/users/user1/merge")) == 2 # a POST isn't retried + + def test_starts_lists_and_ends_sessions_as_a_user(fake: FakeScute) -> None: c = client(fake) started = c.users.impersonate("user1", reason="Ticket 4411", actor={"email": "support@acme.test"}, minutes=15)