Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

<!-- Links -->
[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
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 15 additions & 10 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
125 changes: 122 additions & 3 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions src/testcontainers_atproto/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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,
},
)
Loading
Loading