From 12fa798ffcfffa419a03699d842c4d4af5e04206 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 3 Sep 2026 13:52:00 -0400 Subject: [PATCH] test(auth): drive a token refresh, the path the split was built for Nothing had ever refreshed a token: the fixture plants no refresh token and there was no endpoint to spend one at, so the write every command makes was reachable only through unit tests of the pieces. It is the operation both the per-identity file and the per-identity lock exist for -- the lock is held across the token request inside it -- and it is the path of the incident this tree documents, where a refresh presenting the wrong `client_id` is answered `invalid_grant` and the session is deleted over it. The test logs in, expires the access token, and asserts a neighbouring identity's file is untouched and the grant survives. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I2f5bd7ff9fedaf5f8495d23a3e1134761672d072 --- tests/login_layout.rs | 159 ++++++++++++++++++++++++++++++++---------- 1 file changed, 123 insertions(+), 36 deletions(-) diff --git a/tests/login_layout.rs b/tests/login_layout.rs index bdb8c97..c9a5792 100644 --- a/tests/login_layout.rs +++ b/tests/login_layout.rs @@ -17,23 +17,17 @@ mod support; use support::{ALICE, Scenario}; -/// A login creates the identity's directory, and everything in it. -#[test] -fn a_login_creates_the_layout_it_later_reads() { - let world = Scenario::new("login-layout"); - - // Nothing planted: this is a machine where nobody has ever logged in. +/// Log in for real, and answer where the identity's directory landed. +/// +/// The callback is delivered rather than consented to: `auth login` prints +/// the authorization URL and waits, so a test reads that line, posts the +/// redirect the browser would have posted, and lets the command finish. +fn log_in(world: &Scenario) -> std::path::PathBuf { let config = world.config_path(""); - std::fs::remove_dir_all(&config).expect("clear the fixture's config"); - std::fs::create_dir_all(&config).expect("an empty config directory"); - let mut login = world.command(&["auth", "login", "alice.test"]).start(); - // Off a terminal the first line of stdout is the authorization URL and - // nothing else, printed before the wait begins so that whatever is - // driving the login can act on it. let url = login.first_line(); if !url.starts_with("http") { - let run = login.finish(); + let run = login.kill(); panic!( "no authorization URL (got {url:?})\n--- stderr ---\n{}", run.stderr @@ -43,8 +37,7 @@ fn a_login_creates_the_layout_it_later_reads() { // Not from the URL: with a pushed authorization request the parameters // are at the server and the URL carries only `client_id` and // `request_uri`. atgc records the state it will accept in `pending.json` - // before it prints the URL — which is the same file `logout --all` - // clears, and the reason it is not any identity's. + // before it prints the URL. let pending: serde_json::Value = serde_json::from_slice( &std::fs::read(config.join("pending.json")).expect("a pending authorization request"), ) @@ -56,28 +49,16 @@ fn a_login_creates_the_layout_it_later_reads() { .find_map(|k| k.strip_prefix("oauth-state:")) .expect("a recorded state") .to_string(); - // The port is in the `client_id`, which for a loopback client is a - // `http://localhost/` URL with the redirect URI folded into its query - // string — percent-encoded, so the colon arrives as `%3A`. - // The port is in the `client_id`, which for a loopback client is a - // `http://localhost/` URL with the redirect URI folded into its query - // string. That URI is percent-encoded inside a percent-encoded value, so - // the colon arrives as `%253A` — hence skipping whatever separator is - // there rather than matching one spelling of it. + // From the redirect URI specifically. The authorization endpoint is on - // 127.0.0.1 too in a test, and taking the first match aimed the callback - // at the mock PDS -- which answered nothing, so the login sat out its - // whole five-minute wait. + // 127.0.0.1 too in a test, and the separator is a percent-encoded colon + // inside a percent-encoded value -- `%253A` -- so both the first match + // and a "skip non-digits" scan pick the wrong number. let port = url .split("redirect_uri") .nth(1) .and_then(|rest| rest.split("127.0.0.1").nth(1)) .map(|rest: &str| { - // The separator is a percent-encoded colon inside a - // percent-encoded value: `%253A`. Skipping "non-digits" ate the - // `%` and then took `253` as the port, so the callback went to a - // port nothing was listening on and the login sat out its whole - // five-minute wait. Strip the separator by name instead. rest.trim_start_matches("%253A") .trim_start_matches("%3A") .trim_start_matches(':') @@ -87,23 +68,43 @@ fn a_login_creates_the_layout_it_later_reads() { }) .filter(|p| !p.is_empty()) .unwrap_or_else(|| panic!("no loopback port in {url}")); + let callback = format!("http://127.0.0.1:{port}/oauth/callback?code=test-code&state={state}"); let deliver = std::thread::spawn(move || { - // The listener is up before the URL was printed, so this cannot race. let _ = std::process::Command::new("curl") .args(["-s", "-o", "/dev/null", "--max-time", "20", &callback]) .status(); }); - let run = login.finish(); deliver.join().expect("deliver the callback"); assert_eq!(run.code, Some(0), "the login failed:\n{}", run.stderr); - // The tree atgc made, not one a fixture made. - let dir = config + config .join("dids") .join("plc") - .join(ALICE.strip_prefix("did:plc:").expect("alice is a did:plc")); + .join(ALICE.strip_prefix("did:plc:").expect("alice is a did:plc")) +} + +/// A login creates the identity's directory, and everything in it. +#[test] +fn a_login_creates_the_layout_it_later_reads() { + let world = Scenario::new("login-layout"); + + // Nothing planted: this is a machine where nobody has ever logged in. + let config = world.config_path(""); + std::fs::remove_dir_all(&config).expect("clear the fixture's config"); + std::fs::create_dir_all(&config).expect("an empty config directory"); + + // The tree atgc made, not one a fixture made. + let dir = log_in(&world); + assert_eq!( + dir, + config + .join("dids") + .join("plc") + .join(ALICE.strip_prefix("did:plc:").expect("alice is a did:plc")), + "the login did not use the layout the scheme describes" + ); for name in ["did", "account.json", "session.json"] { assert!( dir.join(name).exists(), @@ -152,3 +153,89 @@ fn a_login_creates_the_layout_it_later_reads() { "the account it just created holds no grant:\n{alice:#}" ); } + +/// A token refresh rewrites one identity's file and nobody else's. +/// +/// **The path the whole split was for, and one no test could reach before**: +/// the fixture plants `refresh_token: None`, so a refresh was impossible to +/// drive, and the mock served no token endpoint to drive it against. Both the +/// per-identity file and the per-identity lock exist for this one operation — +/// it is the write every command makes, and the lock is held across the token +/// request it contains. +/// +/// It is also the path of the incident this tree documents: a refresh that +/// presents a `client_id` the grant was not issued to is answered +/// `invalid_grant`, which jacquard classifies permanent and deletes the +/// session over. So the assertion is not only that a refresh works, but that +/// what it leaves behind is still a grant. +#[test] +fn a_refresh_rewrites_one_identity_and_leaves_the_rest_alone() { + let world = Scenario::new("login-refresh"); + let config = world.config_path(""); + std::fs::remove_dir_all(&config).expect("clear the fixture's config"); + std::fs::create_dir_all(&config).expect("an empty config directory"); + + let dir = log_in(&world); + let shard = dir.join("session.json"); + let entry = dir.join("account.json"); + let client_id_before = serde_json::from_slice::( + &std::fs::read(&entry).unwrap(), + ) + .unwrap()["human"]["client_id"] + .as_str() + .expect("the login recorded a client_id") + .to_string(); + + // A neighbour, so "only this identity's file" is a claim with something + // to be wrong about. Written by hand: it stands for an account that is + // simply present, and a refresh must not touch it. + let neighbour = config.join("dids").join("web").join("bob.example"); + std::fs::create_dir_all(&neighbour).unwrap(); + std::fs::write(neighbour.join("did"), "did:web:bob.example").unwrap(); + std::fs::write(neighbour.join("session.json"), "{}").unwrap(); + let neighbour_before = std::fs::read(neighbour.join("session.json")).unwrap(); + + // Expire the access token. The refresh token outlives it, which is the + // ordinary state of a session an hour after login. + let mut store: serde_json::Value = + serde_json::from_slice(&std::fs::read(&shard).unwrap()).expect("json"); + let key = store + .as_object() + .unwrap() + .keys() + .next() + .expect("a session") + .clone(); + store[&key]["ClientSession"]["expires_at"] = serde_json::json!("2020-01-01T00:00:00Z"); + store[&key]["ClientSession"]["access_token"] = serde_json::json!("stale-token"); + std::fs::write(&shard, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + + // Any command that acts refreshes first. + let run = world + .command(&["auth", "token"]) + .env("ATGC_ACCOUNT", ALICE) + .finish() + .success(); + assert_ne!( + run.stdout.trim(), + "stale-token", + "the expired token was handed out rather than refreshed" + ); + + // The grant survived. A refresh that loses the `client_id` leaves an + // account that cannot refresh again. + let after: serde_json::Value = + serde_json::from_slice(&std::fs::read(&entry).unwrap()).expect("json"); + assert_eq!( + after["human"]["client_id"].as_str(), + Some(client_id_before.as_str()), + "the refresh lost the client_id the grant was issued to:\n{after:#}" + ); + + // And the neighbour was not rewritten. + assert_eq!( + std::fs::read(neighbour.join("session.json")).unwrap(), + neighbour_before, + "a refresh of one identity rewrote another's sessions" + ); +} -- knotmirror 0.0.1