Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321//! `atgc api`, driven against the mock services.//!//! The escape hatch has one claim no unit test can check, and it is the claim//! the whole design rests on: **the host decides the credential.** A call to//! the PDS goes out under the session's own access token, a knot procedure//! goes out under a service-auth JWT the PDS minted for that one method, and//! a public read goes out under nothing. Every one of those produces a//! well-formed request, so a mix-up is invisible to anything that only reads//! the code — and the worst of them, handing a third-party knot a credential//! for the PDS, would be invisible to the user too.//!//! What the journal in `tests/support/` makes observable is exactly that://! which service took the call, in what order, and whose credentials were//! presented. See `tests/support/mod.rs` for the environment and the argument//! for it.
mod support;
use support::{ALICE, KNOT, PULL_NSID, Scenario};
/// A query: parameters in the query string, the service's own answer on/// stdout, and the session's token on the request.////// The parameters are the half a unit test cannot see. `-f` and `-F` build a/// URL, and a URL that dropped one of them would still come back with a/// valid-looking page — of the wrong collection, or of none — which is/// exactly what a `listRecords` missing its `collection` produces.#[test]fn a_query_sends_its_parameters_and_prints_what_came_back() { let world = Scenario::new("api-query"); world.with(|w| { w.plant( ALICE, PULL_NSID, "3aaa", serde_json::json!({ "title": "a pull", "createdAt": "2026-01-01T00:00:00Z" }), ); }); world.clear_journal();
let run = world .run(&[ "api", "com.atproto.repo.listRecords", "-f", &format!("repo={ALICE}"), "-f", &format!("collection={PULL_NSID}"), "-F", "limit=5", ]) .success();
// The PDS's own envelope, re-emitted whole rather than summarized: this // command has no derived view and is not supposed to grow one. let answer = run.json(); let records = answer["records"].as_array().expect("a records array"); assert_eq!(records.len(), 1, "{answer:#}"); assert_eq!(records[0]["value"]["title"], "a pull");
world.with(|w| { let calls = w.calls_to("com.atproto.repo.listRecords"); assert_eq!(calls.len(), 1, "one request, not a retry: {:?}", w.labels()); let call = calls[0]; assert_eq!(call.params.get("repo").map(String::as_str), Some(ALICE)); assert_eq!( call.params.get("collection").map(String::as_str), Some(PULL_NSID) ); // `-F limit=5` is a JSON number in a body and plain text in a query // string; `limit="5"` would be neither a number nor a limit. assert_eq!(call.params.get("limit").map(String::as_str), Some("5")); // And it went out as the acting account, which is what // "authenticated escape hatch" means at a PDS. assert_eq!(call.actor.as_deref(), Some(ALICE)); });}
/// A procedure: a body from `--input`, a record that really lands, and a line/// in the PDS write log.////// The log half is as much the point as the write. Every durable thing atgc/// does is supposed to be in `pds.jsonl`, and a raw hatch able to create a/// record outside it would be the one hole in that claim — so this drives the/// write through `api` and reads it back with the tool's own log reader.#[test]fn a_procedure_sends_its_body_as_the_account_and_is_recorded_as_a_write() { let world = Scenario::new("api-procedure"); std::fs::write( world.checkout.path.join("record.json"), serde_json::to_vec(&serde_json::json!({ "repo": ALICE, "collection": PULL_NSID, "rkey": "3bbb", "record": { "$type": PULL_NSID, "title": "written raw" }, })) .expect("serialize the body"), ) .expect("write the body file"); world.clear_journal();
let run = world .run(&[ "api", "com.atproto.repo.createRecord", "--input", "record.json", ]) .success(); let answer = run.json(); assert!( answer["uri"] .as_str() .expect("createRecord answers with a uri") .ends_with("3bbb"), "{answer:#}" );
world.with(|w| { let record = w .get(ALICE, PULL_NSID, "3bbb") .expect("the record landed in Alice's repository"); assert_eq!(record.value["title"], "written raw"); let calls = w.calls_to("com.atproto.repo.createRecord"); assert_eq!(calls.len(), 1); assert_eq!(calls[0].actor.as_deref(), Some(ALICE)); // The whole body, not a re-serialization of the fields: `--input` is // the only way to send a record, which is an object no `-f` can // spell. assert_eq!(calls[0].body["record"]["title"], "written raw"); });
// `pds.jsonl` sits under the scenario's own HOME, so the tool reads its // own log back. let log = world.run(&["logs", "pds"]).success(); assert!( log.stdout.contains("create"), "the api write is missing from the PDS log:\n{}", log.stdout ); assert!( log.stdout.contains(PULL_NSID) && log.stdout.contains("3bbb"), "the log line does not name what was written:\n{}", log.stdout );}
/// A knot procedure carries a service-auth JWT minted for that one method,/// and the session's own token never leaves the PDS.////// This is the credential switch, and the reason `--host` exists at all. The/// sequence is the assertion: `getServiceAuth` at the PDS, bound to this/// audience and this method, and only then the knot — rather than one request/// to the knot with an OAuth token on it, which is what a design that made/// the user name the credential would produce the first time somebody/// guessed.#[test]fn a_knot_procedure_mints_a_token_for_that_method_and_nothing_else() { let world = Scenario::new("api-knot"); world.clear_journal();
world .run(&[ "api", "sh.tangled.repo.merge", "--host", &format!("knot:{KNOT}"), "-f", "repo=at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/sh.tangled.repo/demo", "-X", "post", ]) .success();
world.with(|w| { let labels = w.labels(); let minted = labels .iter() .position(|l| l == "pds com.atproto.server.getServiceAuth"); let sent = labels .iter() .position(|l| l == "knot sh.tangled.repo.merge"); assert!(minted.is_some(), "no service-auth was minted: {labels:?}"); assert!(minted < sent, "the knot was called first: {labels:?}");
let mint = w.calls_to("com.atproto.server.getServiceAuth")[0]; assert_eq!( mint.params.get("aud").map(String::as_str), Some(format!("did:web:{KNOT}").as_str()) ); // Bound to this method alone: a token minted for any other `lxm` // would authorize a call nobody asked for. assert_eq!( mint.params.get("lxm").map(String::as_str), Some("sh.tangled.repo.merge") );
let call = w.calls_to("sh.tangled.repo.merge")[0]; assert_eq!(call.actor.as_deref(), Some(ALICE)); // `-f` pairs become the procedure's body, not a query string. assert_eq!( call.body["repo"], "at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/sh.tangled.repo/demo" ); assert!(call.params.is_empty(), "{:?}", call.params); });}
/// A refusal prints the service's own `error` and `message`, and exits as the/// kind of refusal it is rather than as a generic 1.////// A status alone cannot tell "no such record" from "no such collection", and/// the XRPC error name is the field that can — so it decides the exit status/// too. `RecordNotFound` is `5`, which is what a caller checking `$?` needs/// in order to stop retrying.#[test]fn a_refusal_says_what_the_service_said_and_exits_as_that_kind() { let world = Scenario::new("api-refusal"); let run = world.run(&[ "api", "com.atproto.repo.getRecord", "-f", &format!("repo={ALICE}"), "-f", &format!("collection={PULL_NSID}"), "-f", "rkey=nothing-here", ]); assert_eq!(run.code, Some(5), "stderr:\n{}", run.stderr); assert!( run.stderr.contains("RecordNotFound"), "the service's own error name is the useful half:\n{}", run.stderr ); assert!( run.stdout.is_empty(), "stdout is the answer, and there was none:\n{}", run.stdout );}
/// `--dry-run` sends nothing and prints no credential.////// Both halves matter and they fail differently. A dry run that sent the/// request would be the worst possible bug in a command that can reach/// `deleteRecord`; a dry run that printed the access token would hand it to/// whatever captured the output, which for an agent or a CI job is a log/// somebody else can read.#[test]fn a_dry_run_sends_nothing_and_prints_no_token() { let world = Scenario::new("api-dry-run"); world.with(|w| { w.plant( ALICE, PULL_NSID, "3ccc", serde_json::json!({ "title": "still here" }), ); }); std::fs::write( world.checkout.path.join("delete.json"), serde_json::to_vec(&serde_json::json!({ "repo": ALICE, "collection": PULL_NSID, "rkey": "3ccc", })) .expect("serialize the body"), ) .expect("write the body file"); world.clear_journal();
let run = world .run(&[ "api", "com.atproto.repo.deleteRecord", "--input", "delete.json", "--dry-run", ]) .success(); let report = run.json(); assert_eq!(report["dry_run"], true); assert_eq!(report["http_method"], "POST"); assert_eq!(report["host"], "pds"); assert!( report["url"] .as_str() .expect("a url") .ends_with("/xrpc/com.atproto.repo.deleteRecord"), "{report:#}" ); assert_eq!(report["body"]["rkey"], "3ccc");
let authorization = report["headers"]["authorization"] .as_str() .expect("the credential is described"); assert!( authorization.starts_with("DPoP <redacted"), "{authorization}" ); // The fixture gives each account a distinct, readable access token, which // is what makes this checkable at all. assert!( !run.stdout.contains("access-token-for-alice.test"), "a dry run printed the access token:\n{}", run.stdout );
world.with(|w| { assert!( w.get(ALICE, PULL_NSID, "3ccc").is_some(), "a dry run deleted the record" ); assert!( w.calls_to("com.atproto.repo.deleteRecord").is_empty(), "a dry run reached the PDS: {:?}", w.labels() ); });}