//! `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