//! `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 { 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 ` 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//` 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 = 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:?}" ); }