From 31afd76981aa0f88481161a62008e6af15496a1f Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 18:06:47 -0500 Subject: [PATCH 1/7] introduces Account lifecycle methods and meands of control --- src/testcontainers_atproto/account.py | 45 +++ src/testcontainers_atproto/container.py | 88 +++++- tests/integration/test_account_lifecycle.py | 314 ++++++++++++++++++++ tests/unit/test_account.py | 28 ++ tests/unit/test_container.py | 22 ++ 5 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 tests/integration/test_account_lifecycle.py diff --git a/src/testcontainers_atproto/account.py b/src/testcontainers_atproto/account.py index ccc4243..ccf2cd6 100644 --- a/src/testcontainers_atproto/account.py +++ b/src/testcontainers_atproto/account.py @@ -208,3 +208,48 @@ def reset_password(self, token: str, new_password: str) -> None: "com.atproto.server.resetPassword", data={"token": token, "password": new_password}, ) + + # --- Account Lifecycle --- + + def deactivate(self, delete_after: Optional[str] = None) -> None: + """Deactivate this account. Use activate() to re-enable.""" + data: dict = {} + if delete_after is not None: + data["deleteAfter"] = delete_after + self._pds.xrpc_post( + "com.atproto.server.deactivateAccount", + data=data or None, + auth=self._access_jwt, + ) + + def activate(self) -> None: + """Re-activate a previously deactivated account.""" + self._pds.xrpc_post( + "com.atproto.server.activateAccount", + auth=self._access_jwt, + ) + + def check_account_status(self) -> dict: + """Check this account's status (activated, validDid, repo stats).""" + return self._pds.xrpc_get( + "com.atproto.server.checkAccountStatus", + auth=self._access_jwt, + ) + + def request_account_delete(self) -> None: + """Request deletion token via email. Requires email_mode='capture'.""" + self._pds.xrpc_post( + "com.atproto.server.requestAccountDelete", + auth=self._access_jwt, + ) + + def delete_account(self, password: str, token: str) -> None: + """Delete this account permanently.""" + self._pds.xrpc_post( + "com.atproto.server.deleteAccount", + data={ + "did": self._did, + "password": password, + "token": token, + }, + ) diff --git a/src/testcontainers_atproto/container.py b/src/testcontainers_atproto/container.py index 865f3a3..0c53458 100644 --- a/src/testcontainers_atproto/container.py +++ b/src/testcontainers_atproto/container.py @@ -295,14 +295,11 @@ def create_account( email = email or f"{handle.replace('.', '-')}@test.invalid" password = password or secrets.token_hex(12) - invite_resp = httpx.post( - f"{self.base_url}/xrpc/com.atproto.server.createInviteCode", - json={"useCount": 1}, - auth=("admin", self._admin_password), - timeout=10.0, + invite_data = self.admin_post( + "com.atproto.server.createInviteCode", + data={"useCount": 1}, ) - invite_resp.raise_for_status() - invite_code = invite_resp.json()["code"] + invite_code = invite_data["code"] data = self.xrpc_post( "com.atproto.server.createAccount", @@ -325,6 +322,32 @@ def create_account( # --- Raw XRPC --- + def admin_get(self, method: str, params: Optional[dict] = None) -> dict: + """Raw XRPC query with HTTP Basic admin auth.""" + resp = httpx.get( + f"{self.base_url}/xrpc/{method}", + params=params, + auth=("admin", self._admin_password), + timeout=10.0, + ) + _raise_for_xrpc_status(resp, method) + if not resp.content: + return {} + return resp.json() + + def admin_post(self, method: str, data: Optional[dict] = None) -> dict: + """Raw XRPC procedure with HTTP Basic admin auth.""" + resp = httpx.post( + f"{self.base_url}/xrpc/{method}", + json=data, + auth=("admin", self._admin_password), + timeout=10.0, + ) + _raise_for_xrpc_status(resp, method) + if not resp.content: + return {} + return resp.json() + def xrpc_get( self, method: str, @@ -384,6 +407,57 @@ def xrpc_post( return {} return resp.json() + # --- Admin Operations --- + + def takedown(self, account: Account) -> dict: + """Take down an account via admin API.""" + return self.admin_post( + "com.atproto.admin.updateSubjectStatus", + data={ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": account.did, + }, + "takedown": {"applied": True}, + }, + ) + + def restore(self, account: Account) -> dict: + """Restore a taken-down account.""" + return self.admin_post( + "com.atproto.admin.updateSubjectStatus", + data={ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": account.did, + }, + "takedown": {"applied": False}, + }, + ) + + def get_subject_status(self, account: Account) -> dict: + """Query admin status for an account.""" + return self.admin_get( + "com.atproto.admin.getSubjectStatus", + params={"did": account.did}, + ) + + def disable_invite_codes( + self, + codes: Optional[list[str]] = None, + accounts: Optional[list[str]] = None, + ) -> None: + """Disable invite codes via admin API.""" + data: dict = {} + if codes is not None: + data["codes"] = codes + if accounts is not None: + data["accounts"] = accounts + self.admin_post( + "com.atproto.admin.disableInviteCodes", + data=data, + ) + # --- Health --- def health(self) -> dict: diff --git a/tests/integration/test_account_lifecycle.py b/tests/integration/test_account_lifecycle.py new file mode 100644 index 0000000..bcd600c --- /dev/null +++ b/tests/integration/test_account_lifecycle.py @@ -0,0 +1,314 @@ +"""Integration tests: account lifecycle, admin operations, and moderation.""" + +import re +import time + +import httpx +import pytest + +from testcontainers_atproto import PDSContainer, XrpcError + +pytestmark = pytest.mark.requires_docker + + +def _extract_token(pds: PDSContainer, message_id: str) -> str: + """Fetch full message from Mailpit and extract a token.""" + url = pds._mailpit_api_url() + resp = httpx.get(f"{url}/api/v1/message/{message_id}", timeout=10.0) + resp.raise_for_status() + body = resp.json() + text = body.get("Text", "") + match = re.search(r"[?&]code=([A-Za-z0-9_-]+)", text) + if match: + return match.group(1) + match = re.search(r"(?:token|code)[=:]\s*([A-Za-z0-9_-]+)", text, re.IGNORECASE) + if match: + return match.group(1) + match = re.search(r"^\s*([A-Z0-9]{5}-[A-Z0-9]{5})\s*$", text, re.MULTILINE) + if match: + return match.group(1) + raise ValueError(f"Could not extract token from email body:\n{text}") + + +# --- Deactivate / Activate --- + + +class TestDeactivateActivate: + """Account deactivation and reactivation.""" + + def test_deactivate_makes_account_inaccessible(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + alice.deactivate() + + with pytest.raises(XrpcError): + pds.xrpc_get( + "com.atproto.server.getSession", + auth=alice.access_jwt, + ) + + def test_activate_restores_access(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + alice.deactivate() + alice.activate() + + session = pds.xrpc_get( + "com.atproto.server.getSession", + auth=alice.access_jwt, + ) + assert session["did"] == alice.did + + def test_deactivate_with_delete_after(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + # Should not raise — deleteAfter is accepted by the PDS + alice.deactivate(delete_after="2099-01-01T00:00:00Z") + alice.activate() + + def test_deactivation_does_not_affect_other_accounts(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + bob = pds.create_account("bob.test") + + alice.deactivate() + + # Bob is unaffected + session = pds.xrpc_get( + "com.atproto.server.getSession", + auth=bob.access_jwt, + ) + assert session["did"] == bob.did + + +# --- Check Account Status --- + + +class TestCheckAccountStatus: + """Account status queries.""" + + def test_active_account_status(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + status = alice.check_account_status() + assert status["activated"] is True + assert status["validDid"] is True + + def test_status_has_expected_fields(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + status = alice.check_account_status() + expected_fields = [ + "activated", + "validDid", + "repoCommit", + "repoRev", + "repoBlocks", + "indexedRecords", + "privateStateValues", + "expectedBlobs", + "importedBlobs", + ] + for field in expected_fields: + assert field in status, f"Missing field: {field}" + + +# --- Delete Account --- + + +class TestDeleteAccount: + """Account deletion (requires email_mode='capture').""" + + def test_full_delete_flow(self): + with PDSContainer(email_mode="capture") as pds: + alice = pds.create_account( + "alice.test", + password="test-password", + ) + + # Confirm email first (required for account deletion) + alice.request_email_confirmation() + confirm_msg = pds.await_email(alice.email) + confirm_token = _extract_token(pds, confirm_msg["ID"]) + alice.confirm_email(confirm_token) + + # Request account deletion + alice.request_account_delete() + + # Wait for deletion email + time.sleep(1.0) + messages = pds.mailbox(alice.email) + delete_msg = messages[0] + delete_token = _extract_token(pds, delete_msg["ID"]) + + # Delete the account + alice.delete_account("test-password", delete_token) + + # Verify account is gone — session should fail + with pytest.raises(XrpcError): + pds.xrpc_get( + "com.atproto.server.getSession", + auth=alice.access_jwt, + ) + + +# --- Admin Primitives --- + + +class TestAdminPrimitives: + """Raw admin_get and admin_post methods.""" + + def test_admin_get_returns_dict(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + result = pds.admin_get( + "com.atproto.admin.getSubjectStatus", + params={"did": alice.did}, + ) + assert isinstance(result, dict) + assert "subject" in result + + def test_admin_get_raises_for_unknown_method(self): + with PDSContainer() as pds: + with pytest.raises(XrpcError): + pds.admin_get("com.atproto.admin.nonExistentMethod") + + def test_admin_post_creates_invite_code(self): + with PDSContainer() as pds: + result = pds.admin_post( + "com.atproto.server.createInviteCode", + data={"useCount": 1}, + ) + assert "code" in result + + +# --- Takedown / Restore --- + + +class TestTakedownRestore: + """Admin takedown and restore operations.""" + + def test_takedown_blocks_access(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + pds.takedown(alice) + + with pytest.raises(XrpcError): + pds.xrpc_get( + "com.atproto.server.getSession", + auth=alice.access_jwt, + ) + + def test_restore_unblocks_access(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + pds.takedown(alice) + pds.restore(alice) + + session = pds.xrpc_get( + "com.atproto.server.getSession", + auth=alice.access_jwt, + ) + assert session["did"] == alice.did + + def test_takedown_returns_subject(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + result = pds.takedown(alice) + assert "subject" in result + + def test_takedown_does_not_affect_other_accounts(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + bob = pds.create_account("bob.test") + + pds.takedown(alice) + + session = pds.xrpc_get( + "com.atproto.server.getSession", + auth=bob.access_jwt, + ) + assert session["did"] == bob.did + + +# --- Subject Status --- + + +class TestSubjectStatus: + """Admin subject status queries.""" + + def test_active_account_has_subject(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + status = pds.get_subject_status(alice) + assert "subject" in status + + def test_takedown_status_applied(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + pds.takedown(alice) + status = pds.get_subject_status(alice) + assert status["takedown"]["applied"] is True + + def test_restore_status_not_applied(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + pds.takedown(alice) + pds.restore(alice) + status = pds.get_subject_status(alice) + assert status["takedown"]["applied"] is not True + + +# --- Invite Code Management --- + + +class TestInviteCodeManagement: + """Admin invite code operations.""" + + def test_disable_invite_codes_by_account(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + # Should not raise + pds.disable_invite_codes(accounts=[alice.did]) + + def test_disable_invite_codes_empty(self): + with PDSContainer() as pds: + # Should not raise + pds.disable_invite_codes(codes=[], accounts=[]) + + +# --- Round-Trip Tests --- + + +class TestRoundTrips: + """State preservation across lifecycle transitions.""" + + def test_deactivate_activate_preserves_records(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + ref = alice.create_record("app.bsky.feed.post", { + "$type": "app.bsky.feed.post", + "text": "survives deactivation", + "createdAt": "2026-01-01T00:00:00Z", + }) + + alice.deactivate() + alice.activate() + + record = alice.get_record("app.bsky.feed.post", ref.rkey) + assert record["text"] == "survives deactivation" + + def test_takedown_restore_preserves_records(self): + with PDSContainer() as pds: + alice = pds.create_account("alice.test") + ref = alice.create_record("app.bsky.feed.post", { + "$type": "app.bsky.feed.post", + "text": "survives takedown", + "createdAt": "2026-01-01T00:00:00Z", + }) + + pds.takedown(alice) + pds.restore(alice) + + record = alice.get_record("app.bsky.feed.post", ref.rkey) + assert record["text"] == "survives takedown" diff --git a/tests/unit/test_account.py b/tests/unit/test_account.py index 14d0917..0914e54 100644 --- a/tests/unit/test_account.py +++ b/tests/unit/test_account.py @@ -53,3 +53,31 @@ def test_empty_strings_are_preserved(self): def test_unicode_handle(self): account = self._make_account(handle="t\u00e9st.test") assert account.handle == "t\u00e9st.test" + + +class TestAccountLifecycleMethods: + """Account exposes lifecycle methods (no Docker needed).""" + + def _make_account(self) -> Account: + return Account( + pds=None, # type: ignore[arg-type] + did="did:plc:abc123", + handle="alice.test", + access_jwt="eyJ.access.token", + refresh_jwt="eyJ.refresh.token", + ) + + def test_has_deactivate(self): + assert callable(getattr(self._make_account(), "deactivate", None)) + + def test_has_activate(self): + assert callable(getattr(self._make_account(), "activate", None)) + + def test_has_check_account_status(self): + assert callable(getattr(self._make_account(), "check_account_status", None)) + + def test_has_request_account_delete(self): + assert callable(getattr(self._make_account(), "request_account_delete", None)) + + def test_has_delete_account(self): + assert callable(getattr(self._make_account(), "delete_account", None)) diff --git a/tests/unit/test_container.py b/tests/unit/test_container.py index 2c6acb5..cb62911 100644 --- a/tests/unit/test_container.py +++ b/tests/unit/test_container.py @@ -30,3 +30,25 @@ def test_await_email_raises_when_no_mailpit(self): pds._mailpit = None with pytest.raises(RuntimeError, match="Mailpit is not running"): pds.await_email("alice@test.invalid") + + +class TestAdminMethods: + """PDSContainer exposes admin methods (no Docker needed).""" + + def test_has_admin_get(self): + assert callable(getattr(PDSContainer, "admin_get", None)) + + def test_has_admin_post(self): + assert callable(getattr(PDSContainer, "admin_post", None)) + + def test_has_takedown(self): + assert callable(getattr(PDSContainer, "takedown", None)) + + def test_has_restore(self): + assert callable(getattr(PDSContainer, "restore", None)) + + def test_has_get_subject_status(self): + assert callable(getattr(PDSContainer, "get_subject_status", None)) + + def test_has_disable_invite_codes(self): + assert callable(getattr(PDSContainer, "disable_invite_codes", None)) From 986eca49060cc84799ab35fceaddb9ef2935672e Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 18:07:17 -0500 Subject: [PATCH 2/7] updates README with moderation examples --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index bd7d94f..5666b34 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,55 @@ Password reset follows the same pattern: When `email_mode="none"` (the default), email verification is bypassed and no Mailpit container is started. +### Account lifecycle + +Deactivate, reactivate, and delete accounts to test how your app handles state changes: + +```python +with PDSContainer() as pds: + alice = pds.create_account("alice.test") + + # Deactivate — account becomes inaccessible + alice.deactivate() + + # Re-activate — access restored + alice.activate() + + # Check status + status = alice.check_account_status() + assert status["activated"] is True +``` + +Admin operations let you test moderation flows: + +```python +with PDSContainer() as pds: + alice = pds.create_account("alice.test") + + # Takedown — blocks access + pds.takedown(alice) + + # Restore — unblocks access + pds.restore(alice) + + # Query status + status = pds.get_subject_status(alice) +``` + +Account deletion requires `email_mode="capture"` to retrieve the deletion token: + +```python +with PDSContainer(email_mode="capture") as pds: + alice = pds.create_account("alice.test", password="s3cret") + + # ... confirm email first ... + + alice.request_account_delete() + message = pds.await_email(alice.email) + token = extract_token(message) # your extraction logic + alice.delete_account("s3cret", token) +``` + ### Error handling XRPC failures raise `XrpcError` with structured fields: From a971463bf12e96506f2c4df6cb0f22c8567121ad Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 18:07:38 -0500 Subject: [PATCH 3/7] updates ROADMAP --- ROADMAP.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c87c5a8..12c2274 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,7 @@ testcontainers-atproto is a testing infrastructure module for anyone building on | v0.3.0 | Firehose Subscription | Complete | | v0.4.0 | Declarative Seeding | Complete | | v0.5.0 | Email Verification + Password Reset | Complete | -| v0.6.0 | Account Lifecycle + Admin Operations | Planned | +| v0.6.0 | Account Lifecycle + Admin Operations | Complete | | v0.7.0 | Repo Sync | Planned | | v1.0.0 | Hermeticity + Federation | Planned | @@ -174,20 +174,25 @@ Today, `PDS_DEV_MODE=true` silently bypasses email verification. This release ad --- -## v0.6.0 — Account Lifecycle + Admin Operations (Planned) +## v0.6.0 — Account Lifecycle + Admin Operations (Complete) **Theme:** Account state changes and moderation primitives. Production apps must handle accounts that are deactivated, deleted, or taken down by moderators. Today there's no ergonomic way to test what happens when an account disappears or changes state. This release adds account lifecycle methods and admin API access. -- [ ] `Account.deactivate()` — call `com.atproto.server.deactivateAccount` -- [ ] `Account.delete(password)` — call `com.atproto.server.deleteAccount` (requires email token when `email_mode="capture"`) -- [ ] `PDSContainer.admin_get(method, params)` — authenticated admin XRPC query using HTTP Basic auth -- [ ] `PDSContainer.admin_post(method, data)` — authenticated admin XRPC procedure using HTTP Basic auth -- [ ] Admin takedown: disable an account via `com.atproto.admin.updateSubjectStatus` -- [ ] Admin account status query: `com.atproto.admin.getSubjectStatus` -- [ ] Admin invite code management: create and revoke invite codes programmatically -- [ ] Integration tests: deactivate → verify inaccessible, delete → verify gone, takedown → verify blocked, lifecycle round-trips +- [x] `Account.deactivate(delete_after=)` — call `com.atproto.server.deactivateAccount` +- [x] `Account.activate()` — call `com.atproto.server.activateAccount` +- [x] `Account.check_account_status()` — call `com.atproto.server.checkAccountStatus` +- [x] `Account.request_account_delete()` — request deletion token via `com.atproto.server.requestAccountDelete` +- [x] `Account.delete_account(password, token)` — call `com.atproto.server.deleteAccount` (requires email token via `email_mode="capture"`) +- [x] `PDSContainer.admin_get(method, params)` — authenticated admin XRPC query using HTTP Basic auth +- [x] `PDSContainer.admin_post(method, data)` — authenticated admin XRPC procedure using HTTP Basic auth +- [x] `PDSContainer.takedown(account)` — disable an account via `com.atproto.admin.updateSubjectStatus` +- [x] `PDSContainer.restore(account)` — restore a taken-down account +- [x] `PDSContainer.get_subject_status(account)` — query admin status via `com.atproto.admin.getSubjectStatus` +- [x] `PDSContainer.disable_invite_codes(codes=, accounts=)` — disable invite codes via `com.atproto.admin.disableInviteCodes` +- [x] `create_account` refactored to use `admin_post` internally +- [x] Integration tests: deactivate → verify inaccessible, activate → verify restored, delete → verify gone, takedown → verify blocked, lifecycle round-trips **Outcomes:** - Feed generators and indexers can test their behavior when accounts are removed from the network From 16663854d2470608c078d7a54dd94e7b0af2ace1 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 18:08:34 -0500 Subject: [PATCH 4/7] adds user's guide --- docs/guide.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index ab3e92a..5164d99 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -15,9 +15,10 @@ Spin up ephemeral [PDS](./glossary.md) instances in your Python test suite. [Lex 7. [Declarative Seeding](#declarative-seeding) 8. [Firehose Subscription](#firehose-subscription) 9. [Email Verification and Password Reset](#email-verification-and-password-reset) -10. [Pytest Fixtures](#pytest-fixtures) -11. [Error Handling](#error-handling) -12. [API Reference](#api-reference) +10. [Account Lifecycle and Admin Operations](#account-lifecycle-and-admin-operations) +11. [Pytest Fixtures](#pytest-fixtures) +12. [Error Handling](#error-handling) +13. [API Reference](#api-reference) --- @@ -541,6 +542,124 @@ Token extraction is the test author's responsibility. The PDS email format is ve --- +## Account Lifecycle and Admin Operations + +Test what happens when accounts are deactivated, deleted, or taken down by moderators. Account lifecycle methods use Bearer JWT auth (user-level actions), while admin operations use HTTP Basic auth (server-level actions). + +### Deactivate and activate + +```python +with PDSContainer() as pds: + alice = pds.create_account("alice.test") + + # Deactivate — account becomes inaccessible + alice.deactivate() + + # Re-activate — access restored + alice.activate() + + # Deactivate with a scheduled deletion date + alice.deactivate(delete_after="2099-01-01T00:00:00Z") + alice.activate() +``` + +### Check account status + +```python +status = alice.check_account_status() +assert status["activated"] is True +assert status["validDid"] is True +``` + +The response includes: `activated`, `validDid`, `repoCommit`, `repoRev`, `repoBlocks`, `indexedRecords`, `privateStateValues`, `expectedBlobs`, `importedBlobs`. + +### Delete account + +Account deletion follows the same two-step pattern as password reset: request a token via email, then complete the deletion. + +```python +with PDSContainer(email_mode="capture") as pds: + alice = pds.create_account("alice.test", password="s3cret") + + # ... confirm email first ... + + # 1. Request deletion token + alice.request_account_delete() + + # 2. Retrieve the email and extract the token + message = pds.await_email(alice.email) + token = extract_token(message) # your extraction logic + + # 3. Delete permanently + alice.delete_account("s3cret", token) +``` + +### Account lifecycle methods + +| Method | Auth | Description | +|--------|------|-------------| +| `deactivate(delete_after=)` | Bearer JWT | Deactivate the account | +| `activate()` | Bearer JWT | Re-activate a deactivated account | +| `check_account_status()` | Bearer JWT | Query account status and repo stats | +| `request_account_delete()` | Bearer JWT | Send deletion token email | +| `delete_account(password, token)` | None | Permanently delete the account | + +### Admin raw XRPC + +For admin [XRPC](./glossary.md) methods not covered by helpers, use `admin_get` and `admin_post`. These work like `xrpc_get`/`xrpc_post` but use HTTP Basic auth with the admin password instead of Bearer JWT. + +```python +# Query +status = pds.admin_get( + "com.atproto.admin.getSubjectStatus", + params={"did": alice.did}, +) + +# Procedure +invite = pds.admin_post( + "com.atproto.server.createInviteCode", + data={"useCount": 1}, +) +``` + +### Takedown and restore + +Admin moderation actions — take down an account to block access, restore to unblock: + +```python +with PDSContainer() as pds: + alice = pds.create_account("alice.test") + + # Takedown — blocks all access + pds.takedown(alice) + + # Restore — unblocks access + pds.restore(alice) + + # Query current status + status = pds.get_subject_status(alice) +``` + +### Invite code management + +```python +pds.disable_invite_codes(accounts=[alice.did]) +pds.disable_invite_codes(codes=["invite-code-1"], accounts=[]) +``` + +### Admin operations summary + +| Method | Description | +|--------|-------------| +| `admin_get(method, params=)` | Raw admin XRPC query (HTTP Basic auth) | +| `admin_post(method, data=)` | Raw admin XRPC procedure (HTTP Basic auth) | +| `takedown(account)` | Take down an account | +| `restore(account)` | Restore a taken-down account | +| `get_subject_status(account)` | Query admin status for an account | +| `disable_invite_codes(codes=, accounts=)` | Disable invite codes | + +--- + ## Pytest Fixtures After installing the package, these fixtures are available automatically via the `pytest11` entry point — no imports needed: From b43a43e8b61574d12ea7c96f25f8bad48c0f730d Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 18:08:44 -0500 Subject: [PATCH 5/7] updates CHANGELOG --- CHANGELOG.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 158f7e1..0e017f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] + +### Added + +- `PDSContainer.admin_get(method, params=)` — raw XRPC query with HTTP Basic admin auth +- `PDSContainer.admin_post(method, data=)` — raw XRPC procedure with HTTP Basic admin auth +- `PDSContainer.takedown(account)` — take down an account via `com.atproto.admin.updateSubjectStatus` +- `PDSContainer.restore(account)` — restore a taken-down account +- `PDSContainer.get_subject_status(account)` — query admin status for an account via `com.atproto.admin.getSubjectStatus` +- `PDSContainer.disable_invite_codes(codes=, accounts=)` — disable invite codes via `com.atproto.admin.disableInviteCodes` +- `Account.deactivate(delete_after=)` — deactivate account via `com.atproto.server.deactivateAccount` +- `Account.activate()` — re-activate a deactivated account via `com.atproto.server.activateAccount` +- `Account.check_account_status()` — check account status via `com.atproto.server.checkAccountStatus` +- `Account.request_account_delete()` — request deletion token via `com.atproto.server.requestAccountDelete` +- `Account.delete_account(password, token)` — permanently delete account via `com.atproto.server.deleteAccount` +- Integration tests: deactivate/activate, takedown/restore, delete, status queries, round-trips + +### Changed + +- `create_account` now uses `admin_post` internally for invite code creation — invite code failures now raise `XrpcError` instead of `httpx.HTTPStatusError` + ## [0.5.0] ### Added @@ -116,7 +137,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GitHub Actions `publish` workflow (main → Test PyPI, tags → Test+Prod PyPI + GitHub Release with changelog-extracted notes) -[Unreleased]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.5.0...HEAD +[Unreleased]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.6.0...HEAD +[0.6.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.5.0...0.6.0 [0.5.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.4.0...0.5.0 [0.4.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.3.0...0.4.0 [0.3.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.2.0...0.3.0 From 7f68e04651cb7f0a252fded7facb0dcbbd9b669d Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 21:34:55 -0500 Subject: [PATCH 6/7] ensures that xrpc_post data always resolves --- src/testcontainers_atproto/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testcontainers_atproto/account.py b/src/testcontainers_atproto/account.py index ccf2cd6..be788c5 100644 --- a/src/testcontainers_atproto/account.py +++ b/src/testcontainers_atproto/account.py @@ -218,7 +218,7 @@ def deactivate(self, delete_after: Optional[str] = None) -> None: data["deleteAfter"] = delete_after self._pds.xrpc_post( "com.atproto.server.deactivateAccount", - data=data or None, + data=data, auth=self._access_jwt, ) From 8b71b782f4fe263be0a3a4a8afb75b27aca82769 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sat, 18 Apr 2026 21:49:52 -0500 Subject: [PATCH 7/7] corrects assertion for deactivated accounts as they are gettable, but cannot perform actions --- tests/integration/test_account_lifecycle.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_account_lifecycle.py b/tests/integration/test_account_lifecycle.py index bcd600c..86a700a 100644 --- a/tests/integration/test_account_lifecycle.py +++ b/tests/integration/test_account_lifecycle.py @@ -36,16 +36,13 @@ def _extract_token(pds: PDSContainer, message_id: str) -> str: class TestDeactivateActivate: """Account deactivation and reactivation.""" - def test_deactivate_makes_account_inaccessible(self): + def test_deactivate_blocks_repo_operations(self): with PDSContainer() as pds: alice = pds.create_account("alice.test") alice.deactivate() with pytest.raises(XrpcError): - pds.xrpc_get( - "com.atproto.server.getSession", - auth=alice.access_jwt, - ) + alice.list_records("app.bsky.feed.post") def test_activate_restores_access(self): with PDSContainer() as pds: