Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332//! `accounts.json` under commands that read it and commands that rewrite it.//!//! `plan/testing.md` says the `auth` family has no flow test, and it is right//! about why that matters: the store's own unit tests can prove what one write//! produces, and none of them can see what a *command* does with the file it//! found. What follows is not that whole entry — `login`, `refresh` and//! `token` still have nothing — but it is the half of it with a data-loss//! failure behind it.//!//! The registry is read whole, edited, and written back. `load` answers an//! unparseable file with an empty registry, which is the right answer to//! "what accounts do we know about" and the wrong place to start a rewrite://! the write puts the empty one back, and every other account's `client_id`//! goes with it. That field is the loopback redirect URI of the login that//! produced a grant, ephemeral port and all, so nothing can reconstruct it —//! the accounts that lose it need a fresh login, and nothing said so.
mod support;
use support::{ALICE, BOB, CAROL, Scenario};
/// A registry file that is there and does not parse.////// Not a fabricated horror: a half-written file from a killed process, or one/// written by a newer atgc whose shape this one does not know, arrive here the/// same way.fn corrupt_entry(world: &Scenario) -> Vec<u8> { let path = world.account_file(ALICE); let bytes = b"{\"accounts\": {\"did:plc:aaaa\": {\"client_id\": \"http://localh".to_vec(); std::fs::write(&path, &bytes).expect("plant a corrupt entry"); bytes}
/// A rewrite refuses rather than replacing what it could not read.////// `auth default` only wants to set one pointer, and the file it would write/// back holds every account's entry. Refusing leaves the bytes on disk to look/// at, which is the whole difference between a file somebody can recover and/// one that a handle lookup quietly emptied.#[test]fn a_registry_that_cannot_be_read_is_not_overwritten() { let world = Scenario::new("registry-corrupt-write"); let before = corrupt_entry(&world);
// Naming a default no longer touches the registry at all: it is a fact // about the machine and lives in `config.toml`. So the command that used // to be refused over an unreadable `accounts.json` now succeeds, and the // file it could have destroyed is not opened. That is the property worth // pinning -- the refusal existed to protect this file, and moving the // pointer out protects it better than refusing did. world.run(&["auth", "default", ALICE]).success(); let after = std::fs::read(world.account_file(ALICE)).expect("the file is still there"); assert_eq!(after, before, "the registry was rewritten anyway");
// And a write that *is* the registry's still refuses rather than // clobbering it, which is what the guard is for. let run = world .run(&["auth", "logout", ALICE]) .refused("could not be read"); assert!( run.stderr.contains("client_id"), "the refusal has to say what is at stake:\n{}", run.stderr ); let after = std::fs::read(world.account_file(ALICE)).expect("the file is still there"); assert_eq!(after, before, "the registry was rewritten anyway");}
/// And `auth status` still answers over one.////// The command somebody runs *because* something about their accounts is/// wrong may not be the command that a wrong thing stops. The accounts come/// back from the session store, which is a separate file; repairing the handle/// cache is a side errand and is allowed to fail out loud.#[test]fn status_still_answers_over_a_registry_it_cannot_read() { let world = Scenario::new("registry-corrupt-read"); let before = corrupt_entry(&world);
let run = world.run(&["auth", "status"]).success(); assert!( run.stdout.contains("alice.test"), "the account is in the session store and should still be listed:\n{}", run.stdout );
let after = std::fs::read(world.account_file(ALICE)).expect("the file is still there"); assert_eq!(after, before, "a read command rewrote the registry");}
/// Settings that are simply absent are empty ones, and the first write/// creates the file.////// The case the refusal above must not swallow: no file is not a file that/// could not be read, and a first `auth default` on a machine that has never/// had one has to write it.#[test]fn absent_settings_are_written_rather_than_refused() { let world = Scenario::new("settings-absent"); // The fixture writes one, naming the first account; this test wants the // machine that has never had one. let path = world.config_path("config.toml"); std::fs::remove_file(&path).expect("the fixture writes one; this test wants none");
world.run(&["auth", "default", ALICE]).success();
let written = std::fs::read_to_string(&path).expect("settings were written"); assert!( written.contains(ALICE), "the default was not recorded:\n{written}" );}
/// A comment somebody left in the settings survives atgc writing a key.////// The whole reason this file is TOML rather than JSON like everything else/// atgc writes. A serializer that reconstructs the document would drop the/// comment, and a format chosen for comments that eats them is worse than one/// that never offered them.#[test]fn a_hand_written_comment_survives_a_default_being_set() { let world = Scenario::new("settings-comments"); let path = world.config_path("config.toml"); std::fs::write( &path, "# the account I use for personal work\ndefault = \"did:plc:old\"\n\n# keep this\nsomething = 3\n", ) .expect("write settings by hand");
world.run(&["auth", "default", ALICE]).success();
let after = std::fs::read_to_string(&path).expect("settings are still there"); assert!( after.contains("# the account I use for personal work"), "a comment was dropped:\n{after}" ); assert!( after.contains("# keep this"), "a comment was dropped:\n{after}" ); assert!( after.contains("something = 3"), "a key atgc knows nothing about was dropped:\n{after}" ); assert!(after.contains(ALICE), "the default was not set:\n{after}"); assert!( !after.contains("did:plc:old"), "the old default survived:\n{after}" );}
/// `auth token` prints the token and nothing else.////// The contract is in its own doc: the output is piped into other tools, so/// anything else on stdout breaks every one of them. That is the kind of/// promise a flow test exists for — a note printed to the wrong stream, or a/// stray trailing line, is invisible to a unit test of the function and/// fatal to `curl -H "Authorization: Bearer $(atgc auth token)"`.#[test]fn auth_token_prints_the_token_alone() { let world = Scenario::new("auth-token");
let run = world.run(&["auth", "token"]).success();
assert_eq!( run.stdout, format!("access-token-for-alice.test\n"), "stdout must be the token and a newline, with nothing else in it" );}
/// And under `--json` it is one object carrying who it belongs to.////// A token with no account beside it is a footgun on a machine holding/// several: the pointer moves, and a script that cached the token is quietly/// acting as somebody else.#[test]fn auth_token_json_names_the_account_it_belongs_to() { let world = Scenario::new("auth-token-json");
let json = world.run(&["auth", "token", "--json"]).success().json();
assert_eq!( json["access_token"].as_str(), Some("access-token-for-alice.test"), "{json:#}" ); // `WhoJson` is flattened, so the account's fields sit beside the token // rather than under a `who` key. assert_eq!(json["did"].as_str(), Some(ALICE), "{json:#}"); assert_eq!(json["handle"].as_str(), Some("alice.test"), "{json:#}");}
/// `auth logout <handle>` drops that account and leaves the other alone.////// The whole reason logout takes a name: a machine with two accounts on it/// is the ordinary case, and a logout that took the active one as read would/// be a command whose effect depends on state the user is not looking at.#[test]fn auth_logout_drops_one_account_and_keeps_the_rest() { let world = Scenario::new("auth-logout-one"); let before = world.run(&["auth", "status"]).success().stdout; assert!(before.contains("alice.test"), "{before}"); assert!(before.contains("bob.test"), "{before}");
world.run(&["auth", "logout", "bob.test"]).success();
let after = world.run(&["auth", "status"]).success().stdout; assert!( after.contains("alice.test"), "logging out one account took the other with it:\n{after}" ); assert!( !after.contains("bob.test"), "the account logged out is still listed:\n{after}" );}
/// `auth logout --all` empties the store, and `auth status` says so rather/// than printing an empty table.#[test]fn auth_logout_all_leaves_nothing_and_says_so() { let world = Scenario::new("auth-logout-all");
world.run(&["auth", "logout", "--all"]).success();
let run = world.run(&["auth", "status"]).success(); assert!( !run.stdout.contains("alice.test") && !run.stdout.contains("bob.test"), "an account survived --all:\n{}", run.stdout ); let said = format!("{}{}", run.stdout, run.stderr); assert!( said.contains("auth login"), "an empty store should name the way back in:\n{said}" );}
/// A session with nobody in it is not refused for having nobody in it.////// It used to be, on the grounds that the wait could only time out. A caller/// that opens the URL itself ends that wait, and it is now the ordinary way/// this command is run, so the refusal is gone and no flag is needed to get/// past it.////// What this can pin is only that: there is no OAuth mock here — see this/// module's header on what `login` still has nothing for — so the login goes/// on to fail at the authorization server, which is the *next* thing and/// therefore the proof.#[test]fn a_headless_login_is_no_longer_refused_for_being_headless() { let world = Scenario::new("login-headless");
// Asserted on the authorization URL rather than on a later failure. This // used to check that the login died at the authorization server, which // was a proxy for "it got past the headless check" and stopped being one // when the mocks learned to answer: the login now succeeds that far, and // reaching the URL is the property directly. let mut login = world.command(&["auth", "login", "alice.test"]).start(); let url = login.first_line(); let run = login.kill(); assert!( url.starts_with("http"), "a headless login printed no authorization URL:\n{}", run.stderr ); assert!( !run.stderr.contains("cannot start a browser login"), "a headless login was refused for being headless:\n{}", run.stderr );}
/// `--no-input` is the one way to say "open no browser", and it is not what/// carries a headless login through either.////// The flag suppresses a browser launch, which a headless session was never/// going to perform. Pinned so that a future reading of it as a mode switch/// has to break a test: the two runs must reach the same place.#[test]fn no_input_is_not_what_makes_a_headless_login_work() { let world = Scenario::new("login-headless-no-input");
let mut login = world .command(&["auth", "login", "alice.test", "--no-input"]) .start(); let url = login.first_line(); let run = login.kill(); assert!( url.starts_with("http"), "--no-input should reach the authorization server too:\n{}", run.stderr );}
/// A CI job is refused, because the wait there could only end in the timeout.////// The one signal that survived the general refusal, and the reason it did:/// the other three mean nobody is at *this terminal*, which a pipe with a/// program on the far end contradicts every time. CI means there is no/// browser anywhere in reach, so five minutes of a job would burn to reach a/// failure that was certain before the first request went out.#[test]fn a_ci_job_is_refused_rather_than_left_to_time_out() { let world = Scenario::new("login-in-ci");
let run = world .command(&["auth", "login", ALICE]) .env("CI", "1") .finish() .refused_with(2, "cannot log in from a CI job");
assert!( run.stderr.contains("unset CI"), "an inference this coarse has to name its escape hatch:\n{}", run.stderr ); assert!( !run.stderr.contains("could not start login"), "the refusal must come before anything is asked of the network:\n{}", run.stderr );}
/// And `CI=false` is not CI, so it is not refused.////// `crate::env::switch` reads every boolean atgc takes from the environment/// the same way, and a word meaning no has to mean no here too — otherwise a/// variable that says `false` turns a login off.#[test]fn ci_spelled_false_is_not_a_ci_job() { let world = Scenario::new("login-ci-false");
let mut login = world .command(&["auth", "login", "alice.test"]) .env("CI", "false") .start(); let url = login.first_line(); let run = login.kill(); assert!( !run.stderr.contains("cannot log in from a CI job"), "CI=false was read as being in CI:\n{}", run.stderr ); assert!( url.starts_with("http"), "it should have gone on to the authorization server:\n{}", run.stderr );}
/// A handle inside the cache TTL is not looked up again.////// `auth status` used to resolve every account's DID document and only then/// consult the TTL, which put a day-long timer in front of a lock acquisition/// and behind a network round trip — the wrong way round by orders of/// magnitude. On a machine holding an account per agent that is one request/// per agent, to somebody else's host, to print a column.////// Counted rather than timed: the mock records every call it serves, so this/// asserts the request was not made at all rather than that it was quick.#[test]fn a_fresh_handle_is_not_resolved_again() { let world = Scenario::new("status-handle-ttl");
// Stamp both accounts as checked an hour ago. One file each now, so this // is a write per identity rather than a pass over one map. let fresh = chrono::Utc::now() - chrono::TimeDelta::hours(1); for did in [ALICE, BOB] { let path = world.account_file(did); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry["handle_checked_at"] = serde_json::json!(fresh.to_rfc3339()); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap(); }
world.clear_journal(); world.run(&["auth", "status"]).success(); let fresh_lookups = world.with(|w| w.calls("plc").len());
// And a stale one still is, so the cache cannot go permanently wrong. let stale = chrono::Utc::now() - chrono::TimeDelta::days(3); for did in [ALICE, BOB] { let path = world.account_file(did); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry["handle_checked_at"] = serde_json::json!(stale.to_rfc3339()); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap(); }
world.clear_journal(); world.run(&["auth", "status"]).success(); let stale_lookups = world.with(|w| w.calls("plc").len());
// Not zero against some number: `status` also reads the *resolved* // account's document once, for the PDS it prints, and that one is O(1) in // the number of accounts and stays. What must not scale with the listing // is the per-row handle check, so the claim is that fewer documents are // fetched when the cache is warm — and that a cold cache still refills. assert!( fresh_lookups < stale_lookups, "a warm cache fetched as many documents as a cold one: {fresh_lookups} vs {stale_lookups}" ); assert!( stale_lookups > 0, "a handle past the TTL was never re-checked" );}
/// An agent is refused a person's grant, even though it would work.////// This is the whole of what planes are for. Both grants act as the same DID,/// so borrowing the person's would succeed and publish records identical to/// theirs — which is exactly the state that cannot be told apart afterwards,/// and the reason the answer is a refusal rather than a fallback.////// Driven through `auth token`, which resumes the session. A public read like/// `key list` never reaches the check, which is the right shape: an agent can/// still *read* as anybody, and it is acting as somebody that is gated.#[test]fn an_agent_is_refused_a_persons_grant() { let world = Scenario::new("plane-agent-refused");
let run = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .env("CLAUDECODE", "1") .finish() .refused("has your own login here, but no agent login");
assert!( run.stderr.contains("atgc auth login"), "the refusal has to name the repair:\n{}", run.stderr );}
/// And the same command as a person is not refused.////// The pair matters more than either half: a refusal that fired for everybody/// would pass the test above while breaking the tool.#[test]fn a_person_still_uses_a_persons_grant() { let world = Scenario::new("plane-human-allowed");
let run = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); // Asserted on the token, not on the absence of a phrase. The refusal a // person would wrongly get names the login they lack -- "no login of your // own" -- so a test watching for "agent login" was watching for a string // that cannot appear in the failure it guards, and held either way. assert_eq!( run.stdout.trim(), "access-token-for-alice.test", "a person did not get their own grant" );}
/// `CLAUDECODE=false` is a person, like every other boolean atgc reads.#[test]fn the_plane_reads_false_as_a_person() { let world = Scenario::new("plane-false");
let run = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .env("CLAUDECODE", "false") .finish() .success(); assert_eq!( run.stdout.trim(), "access-token-for-alice.test", "CLAUDECODE=false was not read as a person" );}
/// Logging out on one plane must not log the other one out.////// An agent and the person whose account it borrows hold separate grants so/// that either can be given up without the other noticing. A logout that took/// both would make that promise false at the moment it matters most, and the/// person would find out by being asked to log in again for reasons they/// cannot connect to anything they did.#[test]fn logging_out_an_agent_leaves_the_persons_login_alone() { let world = Scenario::new("plane-logout"); world.grant_agent_plane(ALICE, "alice.test");
world .command(&["auth", "logout", ALICE]) .env("CLAUDECODE", "1") .finish() .success();
let entry: serde_json::Value = serde_json::from_slice(&std::fs::read(world.account_file(ALICE)).unwrap()) .expect("an entry"); let alice = &entry; assert!( alice.get("agent").is_none(), "the agent's grant should be gone:\n{entry:#}" ); assert!( alice["human"]["client_id"].is_string(), "the person's grant was taken too:\n{entry:#}" );
let sessions = world.all_sessions(); assert!( sessions.contains(&format!("oauth:{ALICE}/test-session-alice.test")), "the person's session was deleted:\n{sessions}" );}
/// A person's lookup does not fall through to the agent's session.////// A grant made before planes records no session id, so the person's lookup/// falls back to scanning the store — and that scan keeps whichever session/// expires latest, which after an agent logs in is the agent's. Without the/// subtraction, the person silently acts on the agent's grant: same account,/// different credential, and nothing anywhere saying so.#[test]fn a_persons_lookup_skips_a_session_the_agent_has_claimed() { let world = Scenario::new("plane-no-crossover"); world.grant_agent_plane(ALICE, "alice.test");
// Strip the person's recorded session id, leaving the pre-planes shape: // a client_id and nothing naming which session is theirs. let path = world.account_file(ALICE); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry["human"] .as_object_mut() .expect("a person's grant") .remove("session_id"); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();
// As a person, the token has to be the person's session, never the one // the agent grant names. let run = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); // Both directions, and against the *token* rather than the session id: // `auth token` prints a credential, and the fixture's two are // `access-token-for-alice.test` and `agent-token-for-alice.test`. An // assertion against "agent-session" was checking for a string this // command never prints on either path, so it could not have failed. assert_eq!( run.stdout.trim(), "access-token-for-alice.test", "a person was handed the session the agent grant claims" ); assert!( run.stdout.contains("access-token-for-alice.test"), "a person was not handed their own token:\n{}", run.stdout );}
/// `auth token` hands out the acting plane's token and nobody else's.////// It resumes the right session and then used to re-read the store for/// "this account's session", which picks whichever expires latest — so an/// agent could be handed the *person's* access token, the exact credential/// the refusal exists to withhold, on a command whose output is piped/// straight into other tools.#[test]fn auth_token_hands_out_the_acting_planes_token() { let world = Scenario::new("plane-token"); world.grant_agent_plane(ALICE, "alice.test");
let as_agent = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .env("CLAUDECODE", "1") .finish() .success(); assert_eq!( as_agent.stdout.trim(), "agent-token-for-alice.test", "an agent was handed the wrong plane's token" );
let as_person = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); assert_eq!( as_person.stdout.trim(), "access-token-for-alice.test", "a person was handed the wrong plane's token" );}
/// Every `auth` verb that can be driven offline, on both planes at once.////// The verbs were made plane-aware one bug at a time, each found by looking/// rather than by a test, so this is the sweep: one account with a person's/// grant and an agent's, and every command that reads or reports a credential/// asked from both sides. What it pins is not any one answer but the property/// underneath — no verb, on either plane, may surface the other's.////// `login` and `refresh` are absent because neither can be driven here: one/// needs an authorization server and the other a live token endpoint. They are/// the two least covered paths in this change and worth saying so.#[test]fn no_auth_verb_surfaces_the_other_planes_grant() { let world = Scenario::new("plane-sweep"); world.grant_agent_plane(ALICE, "alice.test");
// `status`, as a person: lists people, counts the agents, and does not // print the agent's session details. let person = world .command(&["auth", "status"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); assert!( person.stdout.contains("alice.test"), "the person's own account is missing:\n{}", person.stdout );
// `status --agent`: the agent rows, and the note pointing back. let agents = world .command(&["auth", "status", "--agent"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); assert!( agents.stdout.contains("alice.test"), "the agent login is missing from --agent:\n{}\n{}", agents.stdout, agents.stderr );
// `status --json`: the row says which planes exist, and says both. let json = world .command(&["auth", "status", "--json", "--all"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); let rows = json.json(); let alice = rows["accounts"] .as_array() .expect("accounts") .iter() .find(|a| a["did"] == ALICE) .expect("alice is listed"); let planes: Vec<&str> = alice["planes"] .as_array() .expect("planes") .iter() .map(|p| p.as_str().unwrap()) .collect(); assert_eq!(planes, vec!["human", "agent"], "{alice:#}");
// `default` names an account, not a grant, so it works from either side // and does not disturb the other's credentials. world .command(&["auth", "default", ALICE]) .env("CLAUDECODE", "1") .finish() .success(); let after = world.all_sessions(); assert!( after.contains("agent-token-for-alice.test") && after.contains("access-token-for-alice.test"), "setting the default touched a credential:\n{after}" );}
/// `--all` describes each account by a grant it actually has.////// Listing both planes cannot report every row against one of them: an/// agent-only account asked about as a person has no session, and saying so/// in the mode whose whole point is to leave nothing out is the one answer/// that is certainly wrong.#[test]fn listing_everything_reports_each_account_by_a_grant_it_has() { let world = Scenario::new("plane-all-rows"); world.grant_agent_plane(ALICE, "alice.test");
// Make alice agent-only: no person's grant, no person's session. let path = world.account_file(ALICE); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry.as_object_mut().expect("account").remove("human"); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap(); let store_path = world.session_shard(ALICE); let mut store: serde_json::Value = serde_json::from_slice(&std::fs::read(&store_path).unwrap()).expect("a store"); store .as_object_mut() .unwrap() .remove(&format!("oauth:{ALICE}/test-session-alice.test")); std::fs::write(&store_path, serde_json::to_vec_pretty(&store).unwrap()).unwrap();
let run = world .command(&["auth", "status", "--json", "--all"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); let rows = run.json(); let alice = rows["accounts"] .as_array() .expect("accounts") .iter() .find(|a| a["did"] == ALICE) .expect("alice is listed"); assert_ne!( alice["session"], "missing", "an agent-only account was reported as having no session:\n{alice:#}" );}
/// Logging out of a plane you are not logged in on says so.////// Nothing is removed — correctly, since the person's grant is not the/// agent's to give up — but reporting "logged out" for a no-op tells somebody/// their credentials are gone when they are not, which is the kind of belief/// people act on.#[test]fn logging_out_a_plane_with_no_grant_does_not_claim_to_have() { let world = Scenario::new("plane-logout-nothing");
// Alice has only a person's grant; log out as an agent. let run = world .command(&["auth", "logout", ALICE]) .env("CLAUDECODE", "1") .finish();
let sessions = world.all_sessions(); assert!( sessions.contains(&format!("oauth:{ALICE}/test-session-alice.test")), "the person's session was removed by an agent's logout:\n{sessions}" ); assert!( !run.stdout.contains("logged out"), "it claimed to log out a grant it does not hold:\n{}\n{}", run.stdout, run.stderr );}
/// Naming a default this plane cannot act as says so, where the cause is.////// The pointer is about which account, not which grant, so setting it is/// allowed — but an agent naming an account that holds only a person's login/// has just armed a refusal that will arrive on some unrelated command later./// The precedence advice below it already makes this argument about the env/// var and the checkout; the plane is the same shape of trap.#[test]fn naming_a_default_this_plane_cannot_use_warns_now() { let world = Scenario::new("plane-default-warn");
let run = world .command(&["auth", "default", ALICE]) .env("CLAUDECODE", "1") .finish() .success(); assert!( run.stderr.contains("has no agent login"), "no warning about a default this plane cannot act as:\n{}", run.stderr ); assert!( run.stderr.contains("atgc auth login"), "the warning has to name the repair:\n{}", run.stderr );}
/// An account with a session and no registry entry can still be logged out.////// That is what every login made before the registry existed looks like, and/// the fallback treats such a session as the person's — so a person can act/// as it. Logout has to agree: deciding "is this plane's" from the registry/// alone answers no for exactly the accounts that have no registry entry, and/// then logging out silently does nothing while saying so.#[test]fn an_account_known_only_from_the_store_can_be_logged_out() { let world = Scenario::new("plane-logout-storeonly");
// The pre-registry shape: a session, no account entry. let path = world.account_file(ALICE); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry.as_object_mut().unwrap().remove(ALICE); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();
let run = world.command(&["auth", "logout", ALICE]).finish().success();
let sessions = world.all_sessions(); assert!( !sessions.contains(&format!("oauth:{ALICE}/test-session-alice.test")), "the session survived a logout that claimed to have happened:\n{sessions}" ); // And it has to say so. Removing the session while reporting that nothing // was given up is the same lie as the reverse, told the other way round. assert!( run.stdout.contains("logged out"), "it gave up the session and said it had not:\n{}\n{}", run.stdout, run.stderr );}
/// A grant holding nothing is not a grant.////// Every field of one is `skip_serializing_if`, so an empty grant is `{}` on/// disk — and a phantom one is worse than an absent one. It hides the account/// from the person's listing as though an agent owned it, and because "the/// other plane holds a grant with no session id" is exactly what makes/// deletion refuse to guess, it makes a real session impossible to log out of.#[test]fn a_grant_holding_nothing_does_not_count_as_one() { let world = Scenario::new("plane-phantom-grant");
let path = world.account_file(ALICE); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry["agent"] = serde_json::json!({}); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();
// Still the person's account, and still listed as one. let listed = world .command(&["auth", "status", "--json"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); let rows = listed.json(); let alice = rows["accounts"] .as_array() .expect("accounts") .iter() .find(|a| a["did"] == ALICE) .expect("a phantom agent grant hid the account from its owner"); let planes: Vec<&str> = alice["planes"] .as_array() .unwrap() .iter() .map(|p| p.as_str().unwrap()) .collect(); assert_eq!( planes, vec!["human"], "an empty grant was counted:\n{alice:#}" );
// And the session can still be given up. world.command(&["auth", "logout", ALICE]).finish().success(); let sessions = world.all_sessions(); assert!( !sessions.contains(&format!("oauth:{ALICE}/test-session-alice.test")), "a phantom grant made the session undeletable:\n{sessions}" );}
/// The same, on the other plane.////// The defect this pair guards was never "an empty agent grant counts"; it/// was that the two planes disagreed about what an empty grant meant, so a/// test on one of them could pass while the other stayed broken. They now/// share one accessor, and this is what says so.#[test]fn a_phantom_persons_grant_does_not_hide_an_agents() { let world = Scenario::new("plane-phantom-human"); world.grant_agent_plane(ALICE, "alice.test");
let path = world.account_file(ALICE); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry["human"] = serde_json::json!({}); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();
let listed = world .command(&["auth", "status", "--json", "--all"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); let rows = listed.json(); let alice = rows["accounts"] .as_array() .expect("accounts") .iter() .find(|a| a["did"] == ALICE) .expect("alice is listed"); let planes: Vec<&str> = alice["planes"] .as_array() .unwrap() .iter() .map(|p| p.as_str().unwrap()) .collect(); assert_eq!( planes, vec!["agent"], "an empty person's grant was counted:\n{alice:#}" );}
/// Plant a cached handle that the DID document disagrees with, stamped `age`.////// The disagreement is the whole instrument: the mock serves `alice.test`, so/// a row reading `stale.test` proves no lookup happened and a row reading/// `alice.test` proves one did. Nothing else distinguishes them.fn plant_stale_handle(world: &Scenario, did: &str, age: chrono::TimeDelta) { let path = world.account_file(did); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("an entry"); entry["handle"] = serde_json::json!("stale.test"); entry["handle_checked_at"] = serde_json::json!((chrono::Utc::now() - age).to_rfc3339()); std::fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();}
fn listed_handle(world: &Scenario, args: &[&str]) -> String { let run = world .command(args) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); run.json()["accounts"] .as_array() .expect("accounts") .iter() .find(|a| a["did"] == ALICE) .expect("alice is listed")["handle"] .as_str() .expect("a handle") .to_string()}
/// Listing agents costs no DID document lookups.////// A machine that has provisioned an identity per agent has thousands of/// them, and `auth status --agent` used to be one network round trip per row/// — for a column that cannot have changed, since an agent is named by its/// DID and nobody renames one. The cached handle is the answer, however old/// the stamp on it is.#[test]fn listing_agents_reads_handles_from_cache() { let world = Scenario::new("status-agent-cached"); world.grant_agent_plane(ALICE, "alice.test"); // A week stale, which for a person would be well past the TTL. plant_stale_handle(&world, ALICE, chrono::TimeDelta::days(7));
assert_eq!( listed_handle(&world, &["auth", "status", "--agent", "--json"]), "stale.test", "listing agents went to the DID document" ); assert_eq!( listed_handle( &world, &["auth", "status", "--agent", "--json", "--refresh"] ), "alice.test", "--refresh did not re-read the handle" );}
/// A person's handle is re-read only once the cache has gone stale.////// The same TTL the write side has always used, applied to the round trip/// that produces the value rather than only to the lock that stores it. The/// case somebody is actually debugging — a handle that moved — still works,/// one day later or with `--refresh` now.#[test]fn a_persons_handle_is_re_read_only_once_it_is_stale() { let world = Scenario::new("status-human-ttl");
plant_stale_handle(&world, ALICE, chrono::TimeDelta::hours(1)); assert_eq!( listed_handle(&world, &["auth", "status", "--json"]), "stale.test", "a handle checked an hour ago was looked up again" );
plant_stale_handle(&world, ALICE, chrono::TimeDelta::days(3)); assert_eq!( listed_handle(&world, &["auth", "status", "--json"]), "alice.test", "a handle three days old was not re-read" );}
/// The scope pre-flight reads the acting plane's grant, not the freshest one.////// `require_scope` used to resolve the session itself, through a helper that/// collapsed an account's sessions to whichever expired latest. That was the/// whole answer while one account meant one grant; with two it is a coin/// toss, and the fixture's agent session deliberately outlives the person's,/// so the wrong grant is the one that would win.////// Driven from the *person's* side, because that is the direction where the/// old behaviour is a false refusal rather than a check deferred to the PDS:/// the person's grant carries the scope, the agent's does not, and a/// plane-blind check refuses a command that is perfectly well authorized.#[test]fn the_scope_preflight_reads_the_acting_planes_grant() { let world = Scenario::new("plane-scope-preflight"); world.grant_agent_plane(ALICE, "alice.test"); world.set_plane_scopes( ALICE, "alice.test", "atproto transition:generic repo:sh.tangled.publicKey", "atproto transition:generic", );
// As the person, whose grant carries it: the pre-flight must not refuse. // // `key add` rather than a read: `key list` never reaches the check, so a // test driven through it passes whichever grant is consulted and proves // nothing. This half is the one that fails against the old behaviour -- // the fixture's agent session outlives the person's, so a lookup that // takes the freshest takes the agent's narrower scopes and refuses a // person who is perfectly well authorized. let run = world .command(&[ "key", "add", "--name", "person-key", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHpersonkey p", ]) .env("ATGC_ACCOUNT", ALICE) .finish(); assert!( !run.stderr.contains("missing scope"), "the person was refused on the agent's scopes:\n{}", run.stderr );
// And as the agent, whose grant does not: it must refuse, naming it. let run = world .command(&[ "key", "add", "--name", "k", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHtest k", ]) .env("ATGC_ACCOUNT", ALICE) .env("CLAUDECODE", "1") .finish(); assert!( run.stderr.contains("repo:sh.tangled.publicKey"), "the agent was waved through on the person's scopes:\nSTDERR {}\nSTDOUT {}", run.stderr, run.stdout );}
/// Each identity's sessions are in its own file, holding nothing else.////// The point of the split is the *write*: a token refresh used to rewrite/// every account's record to change one, under one process-global lock, which/// on a machine holding an identity per agent is the whole store for each/// refresh. A file per identity makes that write small. This asserts the/// layout rather than the timing, since the timing is what the layout buys.#[test]fn each_identity_keeps_its_sessions_in_its_own_file() { let world = Scenario::new("session-shards");
// First: atgc must *find* each account through its own routing. Without // this the test only checks where the fixture put the files, and a // production router sent somewhere else entirely would still pass. for (did, handle) in [(ALICE, "alice.test"), (BOB, "bob.test")] { let run = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", did) .finish() .success(); assert_eq!( run.stdout.trim(), format!("access-token-for-{handle}"), "atgc did not read {handle}'s session from its own shard" ); }
for (did, mine, theirs) in [(ALICE, BOB, "alice"), (BOB, ALICE, "bob")] { let path = world.session_shard(did); let text = std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("no shard at {}: {e}", path.display())); assert!( text.contains(&format!("oauth:{did}/")), "{theirs}'s shard does not hold {theirs}:\n{text}" ); assert!( !text.contains(mine), "{theirs}'s shard holds another account:\n{text}" ); }
// And the single store that used to hold both is not there to be read // back into by anything. assert!( !world.config_path("sessions.json").exists(), "the one-file store is still there" );}
/// The default in `config.toml` is what decides when nothing else does.////// Rank five of five, and the only one that lives in a file a person edits./// `the_pointer_decides_only_when_nothing_above_it_does` pins the ranking as/// a pure function; this pins that the value reaching it comes off disk at/// all. Without it the whole file could go unread and only an unrelated stack/// test would notice.#[test]fn the_default_in_the_settings_decides_when_nothing_else_does() { let world = Scenario::new("settings-default-decides"); // Nothing on the command line, nothing in the environment. world.run_unselected(&["auth", "default", BOB]).success();
let run = world.run_unselected(&["auth", "token"]).success(); assert_eq!( run.stdout.trim(), "access-token-for-bob.test", "the default named in config.toml did not decide" );}
/// Forgetting the account the default names takes the pointer with it.////// The pointer and the account it names now live in different files, so/// keeping them consistent is a thing that has to happen rather than a thing/// that follows from the layout. A stale pointer is survivable -- there is a/// warning for a default that cannot be acted as -- but it should not be the/// ordinary outcome of logging out.#[test]fn forgetting_the_default_account_clears_the_pointer() { let world = Scenario::new("settings-default-cleared"); world.run_unselected(&["auth", "default", BOB]).success(); let settings = world.config_path("config.toml"); assert!( std::fs::read_to_string(&settings).unwrap().contains(BOB), "the default was not set to begin with" );
world.run_unselected(&["auth", "logout", BOB]).success();
let after = std::fs::read_to_string(&settings).unwrap_or_default(); assert!( !after.contains(BOB), "the default still names an account that was logged out:\n{after}" );}
/// Naming an account by DID does not depend on every other account being/// readable.////// A DID names the directory it lives in, so holding it is one read. Building/// the whole listing to search it linearly — which is what this used to do —/// meant one unreadable identity anywhere on the machine could stop you/// acting as a perfectly good one. On a machine running an identity per agent/// that is a much likelier state than it sounds.#[test]fn naming_an_account_by_did_survives_a_broken_neighbour() { let world = Scenario::new("lookup-broken-neighbour");
// Bob's session file is there and will not parse. Nothing about alice. std::fs::write(world.session_shard(BOB), "{ this is not json").unwrap();
let run = world .command(&["auth", "token"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); assert_eq!( run.stdout.trim(), "access-token-for-alice.test", "a broken neighbour stopped an account that was fine" );}
/// An identity is found by its directory, not by the marker inside it.////// **The bug this pins was invisible to every other test in this file**,/// because the fixture writes the `did` marker itself while production did/// not: `identity_dir` is the only thing that writes it and only `key create`/// called it, so an account created by logging in had no marker. Enumerating/// by the marker made that account's entry unreadable — no cached handle and,/// far worse, no recorded `client_id`, which is the one field no later login/// can rebuild. A refresh then presents the wrong client, the authorization/// server answers `invalid_grant`, and the session is deleted as permanently/// failed. That is the incident `logging::oauth` exists to describe.////// The DID is rebuilt from `dids/<method>/<id>` instead, which is exact/// because the scheme hashes and truncates nothing.#[test]fn an_identity_is_found_without_the_marker_inside_it() { let world = Scenario::new("identity-no-marker"); let marker = world.identity_dir(ALICE).join("did"); assert!(marker.exists(), "the fixture should write one to remove"); std::fs::remove_file(&marker).unwrap();
let run = world .command(&["auth", "status", "--json"]) .env("ATGC_ACCOUNT", ALICE) .finish() .success(); let alice = run.json()["accounts"] .as_array() .expect("accounts") .iter() .find(|a| a["did"] == ALICE) .expect("alice is listed") .clone(); let planes: Vec<&str> = alice["planes"] .as_array() .expect("planes") .iter() .map(|p| p.as_str().unwrap()) .collect(); assert_eq!( planes, vec!["human"], "the entry was unreadable without the marker, so the account has no \ recorded grant:\n{alice:#}" );}
/// And writing anything into an identity's directory leaves it labelled.////// The marker is not what enumeration depends on any more, but a directory/// that cannot say whose it is is still a directory somebody has to decode a/// path to understand. Every writer goes through `make_identity_dir`.#[test]fn writing_to_an_identity_leaves_it_saying_which_one_it_is() { let world = Scenario::new("identity-marker-written"); let marker = world.identity_dir(ALICE).join("did"); std::fs::remove_file(&marker).unwrap();
// `auth default` writes the machine's settings, not this account's, so // reach for something that writes the account: logging out the *agent* // plane rewrites alice's entry without removing it. world .command(&["auth", "logout", ALICE]) .env("CLAUDECODE", "1") .finish();
assert!( marker.exists(), "a write into the identity's directory left it unlabelled" ); assert_eq!( std::fs::read_to_string(&marker).unwrap().trim(), ALICE, "the marker names the wrong identity" );}
/// One account that cannot be given up does not save the others.////// `--all` is the explicit "forget everything", usually asked in a hurry and/// for a reason. The walk used to stop at the first failure with a `?`, which/// left the accounts before it logged out, the accounts after it still/// holding live refresh tokens, and an error naming neither group. Now every/// account is attempted, the ones that failed are named, and the exit status/// still says the command did not do all of what was asked.#[test]fn logging_everything_out_does_not_stop_at_the_first_account_that_fails() { let world = Scenario::new("logout-all-partial"); // Bob's store will not parse. Nothing else about him is unusual, and the // other two accounts are untouched. std::fs::write(world.session_shard(BOB), b"{ this is not json").expect("corrupt the shard");
let run = world.run(&["auth", "logout", "--all"]); assert_ne!(run.code, Some(0), "a partial logout reported success"); assert!( run.stderr.contains(BOB), "the account still holding credentials is not named:\n{}", run.stderr );
// The other two are gone, which is the half that used not to happen. let listed: Vec<String> = world.run(&["auth", "status", "--json"]).json()["accounts"] .as_array() .expect("accounts") .iter() .filter_map(|a| a["did"].as_str().map(str::to_string)) .collect(); assert!( !listed.iter().any(|d| d == ALICE), "an account after the failure was left logged in: {listed:?}" ); assert!( !listed.iter().any(|d| d == CAROL), "an account after the failure was left logged in: {listed:?}" );
// And Bob is left whole rather than half-forgotten: his entry is still // there, because his credential file still is. assert!( world.account_file(BOB).exists(), "the registry entry was removed while the credential file it describes was not" );
// The audit log says what happened, not what was asked for. This is read // to answer "when did this credential stop being usable", and naming Bob // would answer it wrongly in the direction that matters: the reader would // conclude a live credential is dead. let logged = world.oauth_log(); let logouts: Vec<&serde_json::Value> = logged .iter() .filter(|line| line["event"] == "logout") .collect(); assert_eq!(logouts.len(), 1, "expected one logout event: {logged:?}"); let named: Vec<&str> = logouts[0]["targets"] .as_array() .expect("targets") .iter() .filter_map(|t| t.as_str()) .collect(); assert!( !named.contains(&BOB), "the log records an account whose sessions are still in the store: {named:?}" ); assert!( named.contains(&ALICE) && named.contains(&CAROL), "the log dropped the accounts that were given up: {named:?}" );}