diff --git a/tests/bluesky-pds/PARITY.md b/tests/bluesky-pds/PARITY.md index d45ac2e..c0d9d88 100644 --- a/tests/bluesky-pds/PARITY.md +++ b/tests/bluesky-pds/PARITY.md @@ -5,7 +5,7 @@ Phase-2 P2 mapping table. | recipe-maintainer file | cc-ci file | what's verified | status | |---|---|---|---| | (no health_check.py in the recipe-maintainer corpus) | `tests/bluesky-pds/functional/test_health_check.py` | GETs `/xrpc/_health` (the PDS health endpoint); asserts 200 + JSON with `version` field. Phase-2 health_check aligned with the parity-port convention. | **Phase-2 health_check** | -| `recipe-info/bluesky-pds/tests/goat_account.py` | (deferred to Q4.3 follow-up — needs `goat` CLI in container) | The original creates a test admin account via `abra app run app -- goat pds admin account create ...`. Doable in cc-ci via `lifecycle.exec_in_app` calling the `goat` CLI inside the PDS container, but adds operational complexity (account state cleanup across runs). Deferred to follow-up. | **deferred** | +| `recipe-info/bluesky-pds/tests/goat_account.py` | `tests/bluesky-pds/functional/test_account_and_post.py` | Original: `goat pds describe`, list/cleanup, account create, verify listed, delete, verify gone. cc-ci port preserves the account-lifecycle assertions + adds an **atproto post round-trip** (createSession→createRecord→getRecord, asserts post text round-trips) — the §4.3 prescribed test ("create a test account (goat CLI), create a post via atproto, fetch it back, delete the account"). F2-8 closed. | **ported** | ## Recipe-specific tests (Phase-2 P3, ≥2 beyond parity) diff --git a/tests/bluesky-pds/functional/test_account_and_post.py b/tests/bluesky-pds/functional/test_account_and_post.py new file mode 100644 index 0000000..5d0cb9a --- /dev/null +++ b/tests/bluesky-pds/functional/test_account_and_post.py @@ -0,0 +1,156 @@ +"""bluesky-pds — recipe-specific functional test (Phase 2 P3 §4.3 prescribed create-and-read). + +Plan §4.3 explicitly: "bluesky-pds — create a test account (goat CLI), create a post via +atproto, fetch it back, delete the account." The recipe-maintainer corpus's `goat_account.py` +ports here; we also add the atproto post round-trip — proves the recipe's defining behavior +(account lifecycle + atproto repo CRUD). + +Flow (all account-management via the `goat` CLI inside the PDS container, atproto repo CRUD via +the public XRPC API): +1. `goat pds describe ` (in-container) — assert the recipe-served DID + `did:web:` appears in the output (proves the PDS is self-identifying correctly). +2. `goat pds admin account create --handle --email --password ` to + create a per-run UUID-suffixed test account. Parse the new account's DID from output. +3. `POST /xrpc/com.atproto.server.createSession` (public XRPC) with the new account's handle + + password → obtain accessJwt. +4. `POST /xrpc/com.atproto.repo.createRecord` with collection=`app.bsky.feed.post`, the new + account's DID as `repo`, and a `text` field carrying a unique marker. Parse the returned + `uri` (atproto record URI: `at:///app.bsky.feed.post/`). +5. `GET /xrpc/com.atproto.repo.getRecord?repo=&collection=app.bsky.feed.post&rkey=` + → assert the returned record's `value.text` matches the marker (post round-trip ✓). +6. `goat pds admin account delete ` cleanup (idempotent — best-effort; per-run teardown + would clean it anyway). + +Non-vacuous: every step exercises a different PDS layer (PDS-DID, admin API, public auth, repo +CRUD). A wedged PDS subsystem fails AT its layer. +""" + +from __future__ import annotations + +import os +import re +import secrets +import shlex +import sys +import uuid + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http, lifecycle # noqa: E402 + +PDS_HOST_LOCAL = "http://localhost:3000" + + +def _in_container(domain: str, shell_cmd: str) -> str: + """Run `shell_cmd` inside the PDS app container via exec_in_app (sh -c wrapper).""" + # The admin_pw_flag uses $(cat ...) which only the sh inside the container can expand — + # callers pass the raw shell command including those substitutions. + return lifecycle.exec_in_app(domain, ["sh", "-c", shell_cmd], timeout=120) + + +def _goat_admin(domain: str, args: str) -> str: + """`goat pds admin ` inside the container, with --admin-password from /run/secrets and + --pds-host pointing at localhost:3000 (the PDS's internal listener).""" + cmd = ( + f"goat pds admin {args} " + f'--admin-password "$(cat /run/secrets/pds_admin_password)" ' + f"--pds-host {PDS_HOST_LOCAL} 2>&1" + ) + return _in_container(domain, cmd) + + +def _xrpc_post(domain: str, nsid: str, data: dict, token: str | None = None) -> tuple[int, dict | None]: + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return harness_http.http_post(f"https://{domain}/xrpc/{nsid}", data=data, headers=headers) + + +def _xrpc_get(domain: str, nsid: str, query: str, token: str | None = None) -> tuple[int, dict | None]: + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return harness_http.http_get(f"https://{domain}/xrpc/{nsid}?{query}", headers=headers) + + +def test_account_lifecycle_and_post_roundtrip(live_app): + """Full account + post round-trip via goat CLI + atproto XRPC.""" + domain = live_app + suffix = uuid.uuid4().hex[:8] + handle = f"ccci-{suffix}.{domain}" + email = f"ccci-{suffix}@{domain}" + password = "ccci-" + secrets.token_urlsafe(12) + + # Step 1: PDS describe via goat — recipe self-identifies as did:web: + out = _in_container(domain, f"goat pds describe {PDS_HOST_LOCAL} 2>&1") + assert f"did:web:{domain}" in out, ( + f"goat pds describe did not contain expected DID 'did:web:{domain}'. Output:\n{out[:500]!r}" + ) + + # Step 2: Create account (UUID-suffixed handle = no run-to-run collision) + out = _goat_admin( + domain, + f"account create --handle {shlex.quote(handle)} --email {shlex.quote(email)} " + f"--password {shlex.quote(password)}", + ) + m = re.search(r"did:plc:[a-z0-9]+", out) + assert m, f"goat account create produced no DID. Output:\n{out[:500]!r}" + new_did = m.group(0) + + cleanup_did = new_did # for the finally cleanup + try: + # Step 3: Public-API session create (login as the new account) + s, body = _xrpc_post( + domain, + "com.atproto.server.createSession", + data={"identifier": handle, "password": password}, + ) + assert s == 200, f"createSession HTTP {s}: {body!r}" + token = (body or {}).get("accessJwt") + assert token, f"createSession returned no accessJwt: {body!r}" + + # Step 4: Create a post via atproto repo.createRecord + marker = f"ccci-bskypost-{uuid.uuid4().hex}" + s, body = _xrpc_post( + domain, + "com.atproto.repo.createRecord", + data={ + "repo": new_did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": marker, + "createdAt": "2026-05-28T12:00:00Z", + }, + }, + token=token, + ) + assert s == 200, f"createRecord HTTP {s}: {body!r}" + record_uri = (body or {}).get("uri", "") + # URI format: at:///app.bsky.feed.post/ + assert record_uri.startswith(f"at://{new_did}/app.bsky.feed.post/"), ( + f"unexpected record uri: {record_uri!r}" + ) + rkey = record_uri.rsplit("/", 1)[-1] + assert rkey, f"no rkey in uri: {record_uri!r}" + + # Step 5: Fetch the post back via repo.getRecord — assert text round-trips + s, body = _xrpc_get( + domain, + "com.atproto.repo.getRecord", + f"repo={new_did}&collection=app.bsky.feed.post&rkey={rkey}", + token=token, + ) + assert s == 200, f"getRecord HTTP {s}: {body!r}" + record_value = (body or {}).get("value", {}) + assert record_value.get("text") == marker, ( + f"post text did not round-trip: created={marker!r}, fetched={record_value.get('text')!r}" + ) + assert record_value.get("$type") == "app.bsky.feed.post" + finally: + # Step 6: Best-effort cleanup. (The per-run domain teardown will discard the volume + # too, but we exercise the delete-account path because it's part of §4.3.) + if cleanup_did: + try: + _goat_admin(domain, f"account delete {cleanup_did}") + except Exception: # noqa: BLE001 + pass