//! The gateway against a reference PDS: owners delegate, collaborators push //! and commit through it, and commits to one branch are batched. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::*; use knotless_serve::gateway::Gateway; use knotless_serve::usage::Usage; use knotless_serve::{App, router}; use serde_json::{Value, json}; use std::sync::atomic::Ordering; use std::sync::{Arc, Barrier}; use std::time::Duration; #[path = "gateway/accounts.rs"] mod accounts; #[path = "gateway/opening.rs"] mod opening; #[path = "gateway/org_index.rs"] mod org_index; #[path = "gateway/orgs.rs"] mod orgs; #[path = "gateway/private_flow.rs"] mod private_flow; #[path = "gateway/push.rs"] mod push; #[path = "gateway/reviewed.rs"] mod reviewed; #[path = "gateway/subscriptions.rs"] mod subscriptions; #[path = "gateway/threads.rs"] mod threads; struct Server { base: String, /// Where it keeps its bots' sessions. bots: std::path::PathBuf, did: String, app: Arc, /// Where it counts its usage. usage: std::path::PathBuf, _rt: tokio::runtime::Runtime, _proxy: TlsProxy, _sandbox: Sandbox, } /// A gateway with config and cache of its own, where owners' delegated /// sessions live. fn gateway() -> Server { gateway_for(None) } /// [`gateway`], keeping the sessions of `bot` too, once it logs in. fn gateway_for(bot: Option<&Account>) -> Server { gateway_with(bot, false) } /// [`gateway_for`], an appview too if `indexed`: with an index following /// the PDS's firehose. fn gateway_with(bot: Option<&Account>, indexed: bool) -> Server { gateway_kept(bot, indexed, Duration::ZERO) } /// [`gateway_with`], whose mirrors are read again once `ttl` old. fn gateway_kept(bot: Option<&Account>, indexed: bool, ttl: Duration) -> Server { gateway_run(bot, indexed, ttl, "") } /// [`gateway_kept`], with `admins` (DIDs, comma-separated) its operators. fn gateway_run(bot: Option<&Account>, indexed: bool, ttl: Duration, admins: &str) -> Server { let mut s = Sandbox::new(); s.set("KNOTLESS_PASSWORD", ""); s.set("KNOTLESS_ADMIN_DIDS", admins); if let Some(bot) = bot { s.set("KNOTLESS_BOT_DIDS", &bot.did); } let bots = s.path("bots"); s.set("KNOTLESS_DID_WEB_SCHEME", "http"); let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); // atproto takes a did:web with a port only for localhost. let port = listener.local_addr().unwrap().port(); let base = format!("http://localhost:{port}"); let mut gw = Gateway::new(&base).unwrap(); // Authorization servers reach a gateway, an OAuth client, over https. let proxy = tls_proxy(port); gw.oauth_url = proxy.url.clone(); // Short, so that delegating asks more than once. gw.wait = Duration::from_millis(100); gw.bots = bot.map(|_| bots.clone()); let did = gw.did.clone(); // An appview's own pulls and issues are its own to read, as // `knotless serve` reads them. if indexed { s.set("KNOTLESS_APPVIEW", &base); } let index = indexed.then(|| knotless_serve::index::Index::open(&s.path("index.db")).unwrap()); let mut app = App::build(ctx(&s), ttl, Some(gw), index); let usage = Usage::open(&s.path("usage.db")).unwrap(); Arc::get_mut(&mut app).unwrap().usage = Some(Arc::new(usage)); let router = router(app.clone()); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); // As `knotless serve` counts what repositories are written in: at once // done, with no index. rt.spawn(knotless_serve::languages::follow(app.clone())); if indexed { let firehose = net().pds.replace("http://", "ws://"); rt.spawn(knotless_serve::firehose::watch(app.clone(), firehose)); } Server { base, bots, did, app, usage: s.path("usage.db"), _rt: rt, _proxy: proxy, _sandbox: s, } } /// Whether the gateway has told `gateway.served` for the owner `did`. fn served(server: &Server, did: &str) -> bool { server.app.usage.as_ref().unwrap().flush().unwrap(); let db = rusqlite::Connection::open(&server.usage).unwrap(); let query = "SELECT count(*) FROM account WHERE name = 'gateway.served' AND did = ?1"; db.query_row(query, [did], |r| r.get::<_, u64>(0)).unwrap() > 0 } fn web(mut s: Sandbox) -> Sandbox { s.set("KNOTLESS_DID_WEB_SCHEME", "http"); s } /// A sandbox logged in as `account`, with no password in its environment, /// so it acts only as itself, and a browser that approves as it. fn person(account: Account) -> Sandbox { let mut s = web(Sandbox::with(account)); s.set("KNOTLESS_PASSWORD", ""); s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), "")); let password = s.account.password.clone(); let handle = s.account.handle.clone(); assert_eq!( s.cli(&["login", &handle, "--password-stdin"], password.as_bytes()) .0, 0 ); s } /// `N` people, each a new account logged in: their logins side by side, /// as each takes the PDS a third of a second at least. fn people() -> [Sandbox; N] { let accounts: [Account; N] = std::array::from_fn(|_| account()); std::thread::scope(|s| { let logins = accounts.map(|a| s.spawn(move || person(a))); logins.map(|l| l.join().unwrap()) }) } /// A service token for `s` to call `lxm` on `aud`. fn token(s: &Sandbox, aud: &str, lxm: &str) -> String { let ctx = ctx(s); tokio::runtime::Runtime::new().unwrap().block_on(async { let id = knotless::identity::resolve(&ctx, &s.account.handle) .await .unwrap(); let me = knotless::session::ensure(&ctx, &id).await.unwrap(); knotless::auth::service_token(&ctx, &me, aud, lxm) .await .unwrap() }) } fn post(url: &str, token: Option<&str>, body: Vec, json: bool) -> (u16, String) { let client = reqwest::blocking::Client::new(); let mut req = client.post(url).body(body); if json { req = req.header("content-type", "application/json"); } if let Some(t) = token { req = req.bearer_auth(t); } let res = req.send().unwrap(); (res.status().as_u16(), res.text().unwrap()) } /// `person`, logged in with OAuth rather than a password: scoped to what /// knotless asks for. fn oauth_person(account: Account) -> Sandbox { let mut s = web(Sandbox::with(account)); s.set("KNOTLESS_PASSWORD", ""); s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), "")); let handle = s.account.handle.clone(); assert_eq!(s.cli(&["login", &handle], b"").0, 0); s } /// What an OAuth login calls a gateway for, its scope allows: delegating /// to it, and pushing and committing through it. #[test] fn oauth_logins_reach_a_gateway() { let gw = gateway(); let owner = oauth_person(account()); let collab = oauth_person(account()); let url = owner.url("scoped"); let work = owner.work("w", 2); owner.git(&work, &["push", "-q", &url, "main"]); let (code, out) = owner.cli( &["account", "delegate", &owner.account.handle, &gw.did], b"", ); assert_eq!(code, 0, "{out}"); let shared = owner.cli( &["repo", "share", &url, &collab.account.handle, "--write"], b"", ); assert_eq!(shared.0, 0, "{}", shared.1); let clone = collab.path("clone"); collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]); let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n"); collab.git(&clone, &["push", "-q", "origin", "main"]); let (_, refs) = owner.cli(&["repo", "refs", &url], b""); assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}"); let committed = collab.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "Scoped", "--put", "x", "-", ], b"x\n", ); assert_eq!(committed.0, 0, "{}", committed.1); } /// Pushes `refspec` from `dir` to its origin, as git would: the error, if /// the push is refused. fn refused(s: &Sandbox, dir: &std::path::Path, refspec: &str) -> Option { let (ok, _, err) = s.git_raw(dir, &["push", "-q", "origin", refspec]); (!ok).then_some(err) } #[test] fn protected_branches_hold_collaborators() { let gw = gateway(); let [owner, collab] = people(); let url = owner.url("guarded"); let work = owner.work("w", 2); owner.git(&work, &["push", "-q", &url, "main"]); assert_eq!( owner .cli( &["account", "delegate", &owner.account.handle, &gw.did], b"" ) .0, 0 ); let share = ["repo", "share", &url, &collab.account.handle, "--write"]; assert_eq!(owner.cli(&share, b"").0, 0); // Only the owner says which branches are protected. let (code, out) = collab.cli(&["branch", "protect", &url, "main"], b""); assert_eq!(code, 1, "{out}"); let missing = owner.url("missing"); for bad in [ vec!["branch", "rules", &missing], vec!["branch", "protect", &url, "main", "--bogus"], vec!["branch", "protect", &url, "main", "--merges", "--undo"], vec!["branch", "protect", &url, "main", "--undo"], ] { assert_eq!(owner.cli(&bad, b"").0, 1, "{bad:?}"); } let (code, out) = owner.cli(&["branch", "protect", &url, "main"], b""); assert_eq!((code, out.as_str()), (0, "main\tlinear")); assert_eq!( collab.cli(&["branch", "rules", &url], b"").1, "main\tlinear" ); let clone = collab.path("clone"); collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]); let base = collab.git(&clone, &["rev-parse", "HEAD"]); // A merge commit, of a side branch off where main was. collab.git(&clone, &["checkout", "-q", "-b", "side", "HEAD~1"]); collab.commit_file(&clone, "side", "side\n"); collab.git(&clone, &["checkout", "-q", "main"]); collab.git( &clone, &["merge", "-q", "--no-ff", "-m", "merge side", "side"], ); let err = refused(&collab, &clone, "main").unwrap(); assert!(err.contains("no merge commits"), "{err}"); // A push that moved nothing served the owner nothing. assert!(!served(&gw, &owner.account.did)); // Nor by way of a branch that took it: what counts is what main gains. assert_eq!(refused(&collab, &clone, "main:refs/heads/feature"), None); // One that moved a ref did, though a collaborator pushed it. assert!(served(&gw, &owner.account.did)); let err = refused(&collab, &clone, "main").unwrap(); assert!(err.contains("no merge commits"), "{err}"); // A new protected branch takes none in all its history. owner.cli(&["branch", "protect", &url, "release"], b""); let err = refused(&collab, &clone, "main:refs/heads/release").unwrap(); assert!(err.contains("no merge commits"), "{err}"); // Nor does a commit, made on a base with a merge in its history. let merged = collab.git(&clone, &["rev-parse", "HEAD"]); let note = collab.path("note"); std::fs::write(¬e, "n\n").unwrap(); let path = note.to_string_lossy(); let on_merge = [ "repo", "commit", &url, "-b", "release", "-m", "n", "--base", &merged, "--put", "n", &path, ]; let (done, _, err) = collab.knotless(&on_merge, b""); assert!(!done && err.contains("no merge commits"), "{err}"); collab.git(&clone, &["reset", "-q", "--hard", &base]); let err = refused(&collab, &clone, "+HEAD~1:main").unwrap(); assert!(err.contains("only moves forward"), "{err}"); let err = refused(&collab, &clone, ":main").unwrap(); assert!(err.contains("not deleted"), "{err}"); // Forward, one parent at a time, it lands. let ahead = collab.commit_file(&clone, "f", "linear\n"); assert_eq!(refused(&collab, &clone, "main"), None); let (_, refs) = owner.cli(&["repo", "refs", &url], b""); assert!( refs.contains(&format!("{ahead}\trefs/heads/main")), "{refs}" ); // The owner writes past the gateway, rules or none. owner.git(&work, &["push", "-q", "--force", &url, "main"]); // Merges allowed, merge commits land; history still only moves forward. let (code, out) = owner.cli(&["branch", "protect", &url, "main", "--merges"], b""); assert_eq!((code, out.as_str()), (0, "release\tlinear\nmain\tmerges")); collab.git(&clone, &["fetch", "-q", "origin"]); collab.git(&clone, &["reset", "-q", "--hard", "origin/main"]); collab.git( &clone, &["merge", "-q", "--no-ff", "-m", "merge side", "side"], ); assert_eq!(refused(&collab, &clone, "main"), None); let err = refused(&collab, &clone, "+HEAD~1:main").unwrap(); assert!(err.contains("only moves forward"), "{err}"); let (code, out) = owner.cli(&["branch", "protect", &url, "main", "--undo"], b""); assert_eq!((code, out.as_str()), (0, "release\tlinear")); assert_eq!(refused(&collab, &clone, "+HEAD~1:main"), None); // Taking pulls only through the merge queue, it moves for no // collaborator, by push or by commit, but the runner that lands it. let (code, out) = owner.cli( &["branch", "protect", &url, "main", "--merges", "--queue"], b"", ); assert_eq!( (code, out.as_str()), (0, "release\tlinear\nmain\tmerges\tqueue") ); let err = refused(&collab, &clone, "main").unwrap(); assert!(err.contains("only through the merge queue"), "{err}"); let commit = [ "repo", "commit", &url, "-b", "main", "-m", "n", "--put", "n", &path, ]; let (done, _, err) = collab.knotless(&commit, b""); assert!( !done && err.contains("only through the merge queue"), "{err}" ); // Other branches move as ever. assert_eq!(refused(&collab, &clone, "main:refs/heads/topic"), None); let (code, out) = owner.cli(&["runner", "add", &url, &collab.account.handle], b""); assert_eq!(code, 0, "{out}"); assert_eq!(refused(&collab, &clone, "main"), None); } #[test] fn collaborators_push_and_commit_through_a_gateway() { let gw = gateway(); let [owner, collab, outsider] = people(); let url = owner.url("shared"); let work = owner.work("w", 2); owner.git(&work, &["push", "-q", &url, "main"]); // Sharing for writes needs a gateway to write through. let (code, _) = owner.cli( &["repo", "share", &url, &collab.account.handle, "--write"], b"", ); assert_eq!(code, 1); let (code, out) = owner.cli( &["account", "delegate", &owner.account.handle, &gw.did], b"", ); assert_eq!(code, 0, "{out}"); assert_eq!( owner .cli( &["repo", "share", &url, &collab.account.handle, "--write"], b"" ) .0, 0 ); // A collaborator clones, commits, and pushes as git would. let clone = collab.path("clone"); collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]); let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n"); collab.git( &clone, &["push", "-q", "origin", "main", "main:refs/heads/feature"], ); let (_, refs) = owner.cli(&["repo", "refs", &url], b""); assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}"); assert!( refs.contains(&format!("{sha}\trefs/heads/feature")), "{refs}" ); collab.git(&clone, &["push", "-q", "origin", ":refs/heads/feature"]); let (_, refs) = owner.cli(&["repo", "refs", &url], b""); assert!(!refs.contains("feature"), "{refs}"); // The gateway names the collaborator on what it writes for them: the // delete of the ref it made, whose record went with it, the move of // main, and main itself. let them = Some(collab.account.did.clone()); assert_eq!( pusher(&owner, "dev.knotless.claim", "refs/heads/feature"), them ); assert_eq!( pusher(&owner, "dev.knotless.claim", "refs/heads/main"), them ); assert_eq!(pusher(&owner, "dev.knotless.ref", "refs/heads/main"), them); // The gateway refreshes its grant itself, as the client it was granted // to, once the access token runs out. let held = gw ._sandbox .path("config/sessions") .join(format!("{}.json", owner.account.did.replace(':', "_"))); let mut session: knotless::session::Session = knotless::store::read(&held).unwrap(); let oauth = session.oauth.as_mut().unwrap(); assert!( oauth .client_id .as_ref() .unwrap() .ends_with("/oauth-client-metadata.json") ); oauth.expires_at = 0; let used = session.refresh_jwt.clone(); knotless::store::write(&held, &session, true).unwrap(); collab.git(&clone, &["push", "-q", "origin", "main:refs/heads/again"]); let refreshed: knotless::session::Session = knotless::store::read(&held).unwrap(); assert_ne!(refreshed.refresh_jwt, used); // git's rules hold at the gateway. owner.commit_file(&work, "f", "moved on\n"); owner.git(&work, &["pull", "-q", "--rebase", &url, "main"]); owner.git(&work, &["push", "-q", &url, "main"]); // The owner's own push names no one. assert_eq!(pusher(&owner, "dev.knotless.ref", "refs/heads/main"), None); collab.commit_file(&clone, "theirs", "stale\n"); let (ok, _, err) = collab.git_raw(&clone, &["push", "origin", "main"]); assert!(!ok); assert!( err.contains("non-fast-forward") || err.contains("rejected"), "{err}" ); // Commits without a checkout go through it too, and a base that the // branch has moved on from conflicts on the paths it touched. let note = collab.path("note"); std::fs::write(¬e, "n\n").unwrap(); let (code, out) = collab.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "note", "--put", "note", ¬e.to_string_lossy(), "--json", ], b"", ); assert_eq!(code, 0, "{out}"); let committed: Value = serde_json::from_str(&out).unwrap(); assert_eq!(pusher(&owner, "dev.knotless.ref", "refs/heads/main"), them); let (code, out) = collab.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "late", "--put", "f", ¬e.to_string_lossy(), "--base", &sha, ], b"", ); assert_eq!(code, 1, "f changed since: {out}"); let (code, out) = collab.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "clean", "--put", "elsewhere", ¬e.to_string_lossy(), "--base", committed["sha"].as_str().unwrap(), ], b"", ); assert_eq!(code, 0, "{out}"); let (code, out) = collab.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "tidy", "--delete", "elsewhere", ], b"", ); assert_eq!(code, 0, "{out}"); // A path git refuses fails the batch it is in. let (code, _) = collab.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "bad", "--put", ".git/x", ¬e.to_string_lossy(), ], b"", ); assert_eq!(code, 1); // So does a base the owner alone writes against, directly. let (code, out) = owner.cli( &[ "repo", "commit", &url, "-b", "main", "-m", "late", "--put", "note", ¬e.to_string_lossy(), "--base", &sha, ], b"", ); assert_eq!(code, 1, "{out}"); assert!(out.is_empty()); // Not on the list: refused. let elsewhere = outsider.path("clone"); outsider.git(outsider.dir.path(), &["clone", "-q", &url, "clone"]); outsider.commit_file(&elsewhere, "x", "x\n"); let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", "origin", "main"]); assert!(!ok); assert!(err.contains("not a collaborator"), "{err}"); // A repository with no gateway cannot take anyone else's push. let lonely = owner.url("lonely"); owner.git(&work, &["push", "-q", &lonely, "main"]); let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", &lonely, "main"]); assert!(!ok); assert!(err.contains("only its owner can"), "{err}"); let (code, _) = outsider.cli( &[ "repo", "commit", &lonely, "-b", "main", "-m", "m", "--delete", "f", ], b"", ); assert_eq!(code, 1); } /// The pusher on the newest of `owner`'s records of `collection` for the /// ref `name`. fn pusher(owner: &Sandbox, collection: &str, name: &str) -> Option { let listed: Value = reqwest::blocking::Client::new() .get(format!("{}/xrpc/com.atproto.repo.listRecords", net().pds)) .query(&[ ("repo", owner.account.did.as_str()), ("collection", collection), ]) .send() .and_then(|r| r.json()) .unwrap(); let records = listed["records"].as_array().unwrap(); let newest = records .iter() .map(|r| &r["value"]) .filter(|v| v["name"] == name) .max_by_key(|v| { v["updatedAt"] .as_str() .or(v["createdAt"].as_str()) .map(str::to_string) }) .unwrap(); newest["pusher"].as_str().map(str::to_string) } #[test] fn commits_to_one_branch_are_batched() { let gw = gateway(); let owner = person(account()); let url = owner.url("busy"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); assert_eq!( owner .cli( &["account", "delegate", &owner.account.handle, &gw.did], b"" ) .0, 0 ); let collab = person(account()); assert_eq!( owner .cli( &["repo", "share", &url, &collab.account.handle, "--write"], b"" ) .0, 0 ); let n = 16; let barrier = Arc::new(Barrier::new(n)); let shared = Arc::new((owner, collab)); let handles: Vec<_> = (0..n) .map(|i| { let barrier = barrier.clone(); let shared = shared.clone(); let url = url.clone(); std::thread::spawn(move || { let (owner, collab) = &*shared; let who = if i % 2 == 0 { owner } else { collab }; let file = who.path(&format!("in{i}")); std::fs::write(&file, format!("{i}\n")).unwrap(); barrier.wait(); let mut args = vec![ "repo", "commit", &url, "-b", "main", "-m", "agent", "--json", ]; let path = format!("agent{i}"); let file = file.to_string_lossy().to_string(); args.extend(["--put", &path, &file]); if who.account.did == owner.account.did { args.push("--batch"); } let (code, out) = who.cli(&args, b""); assert_eq!(code, 0, "{out}"); serde_json::from_str::(&out).unwrap() }) }) .collect(); let results: Vec = handles.into_iter().map(|h| h.join().unwrap()).collect(); let (owner, _) = &*shared; let clone = owner.path("check"); owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]); let count = owner.git(&clone, &["rev-list", "--count", "HEAD"]); assert_eq!(count, (n + 1).to_string()); for i in 0..n { assert!(clone.join(format!("agent{i}")).exists()); } assert!(results.iter().all(|r| r["sha"].is_string())); // Fewer writes than commits: they arrived together. let writes = gw .app .gateway .as_ref() .unwrap() .writes .load(Ordering::Relaxed); assert!(writes < n as u64, "{writes} writes for {n} commits"); } #[test] fn racing_gateways_rebuild_on_the_moved_branch() { let (a, b) = (gateway(), gateway()); let owner = person(account()); let url = owner.url("contested"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); for gw in [&a, &b] { assert_eq!( owner .cli( &["account", "delegate", &owner.account.handle, &gw.did], b"" ) .0, 0 ); } let owner = Arc::new(owner); let mut round = 0; while a .app .gateway .as_ref() .unwrap() .rebuilt .load(Ordering::Relaxed) + b.app .gateway .as_ref() .unwrap() .rebuilt .load(Ordering::Relaxed) == 0 { round += 1; assert!(round < 50, "the gateways never raced"); let barrier = Arc::new(Barrier::new(2)); let handles: Vec<_> = [&a.did, &b.did] .into_iter() .enumerate() .map(|(i, did)| { let (owner, barrier, did) = (owner.clone(), barrier.clone(), did.clone()); std::thread::spawn(move || { let req = json!({ "repo": owner.account.did, "name": "contested", "branch": "main", "message": "race", "changes": [{"path": format!("r{round}-{i}"), "data": {"$bytes": "eA"}}], }); let t = token(&owner, &did, "dev.knotless.createCommit"); let base = did.strip_prefix("did:web:").unwrap().replace("%3A", ":"); barrier.wait(); post(&format!("http://{base}/xrpc/dev.knotless.createCommit"), Some(&t), req.to_string().into_bytes(), true) }) }) .collect(); for h in handles { let (status, body) = h.join().unwrap(); assert_eq!(status, 200, "{body}"); } } let (_, refs) = owner.cli(&["repo", "refs", &url], b""); assert!(refs.contains("refs/heads/main"), "{refs}"); } #[test] fn private_repositories_through_a_gateway() { if !net().spaces { eprintln!("skipped: the PDS is not on the spaces alpha"); return; } let gw = gateway(); let [owner, collab] = people(); let url = owner.url("hidden"); assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); assert_eq!( owner .cli( &["account", "delegate", &owner.account.handle, &gw.did], b"" ) .0, 0 ); assert_eq!( owner .cli( &["repo", "share", &url, &collab.account.handle, "--write"], b"" ) .0, 0 ); // The collaborator reads it on the pages first, which keeps a mirror // of it for them alone. let cookie = session_cookie(&gw, &collab.account); let at = format!( "{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/hidden", gw.base, owner.account.did ); let (status, .., shown) = looked(&at, Some(&cookie)); assert_eq!(status, 200, "{shown}"); let underscored = |did: &str| did.replace(':', "_"); let theirs = (gw.app.ctx.env.cache_dir.join("web")) .join(underscored(&collab.account.did)) .join("mirrors") .join(underscored(&owner.account.did)); let mut kept = std::fs::read_dir(&theirs) .unwrap() .map(|e| e.unwrap().path()); let mirror = kept.find(|p| p.extension().is_some_and(|x| x == "git")); let mirror = mirror.expect("a mirror of it, theirs"); let clone = collab.path("clone"); collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]); let sha = collab.commit_file(&clone, "theirs", "private work\n"); collab.git(&clone, &["push", "-q", "origin", "main"]); let (_, refs) = owner.cli(&["repo", "refs", &url], b""); assert_eq!(refs, format!("{sha}\trefs/heads/main")); // What they pushed through the gateway is in their mirror before they // look again. let has = |git: &std::path::Path| { let found = std::process::Command::new("git") .args(["cat-file", "-e", &sha]) .current_dir(git) .status(); found.is_ok_and(|s| s.success()) }; wait_until(|| has(&mirror)); let (_, members) = owner.cli(&["repo", "members", &url], b""); assert_eq!( members, format!( "{}\towner\n{}\twrite", owner.account.did, collab.account.did ) ); // The owner on another machine, which never delegated, keeps the // gateway the record already names. let elsewhere = person(owner.account.clone()); let third = account(); assert_eq!( elsewhere .cli(&["repo", "share", &url, &third.handle, "--write"], b"") .0, 0 ); } #[test] fn gateway_refusals() { let gw = gateway(); let owner = person(account()); let url = owner.url("guarded"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let push = format!("{}/xrpc/dev.knotless.pushRefs", gw.base); let commit = format!("{}/xrpc/dev.knotless.createCommit", gw.base); let delegate = format!("{}/xrpc/dev.knotless.putDelegation", gw.base); let header = |updates: Value| { let mut body = json!({"repo": owner.account.did, "name": "guarded", "updates": updates}) .to_string() .into_bytes(); body.push(b'\n'); body }; // No token, or one for another method or service. assert_eq!(post(&push, None, header(json!([])), false).0, 401); let wrong = token(&owner, &gw.did, "dev.knotless.createCommit"); assert_eq!(post(&push, Some(&wrong), header(json!([])), false).0, 401); let elsewhere = token(&owner, "did:web:elsewhere.test", "dev.knotless.pushRefs"); assert_eq!( post(&push, Some(&elsewhere), header(json!([])), false).0, 401 ); // A refused grant does not delegate; until one is approved, nothing // writes. let mut refuser = person(owner.account.clone()); refuser.set("KNOTLESS_BROWSER", &browser(&owner.account, "--reject")); let (ok, _, err) = refuser.knotless( &["account", "delegate", &owner.account.handle, &gw.did], b"", ); assert!(!ok); assert!(err.contains("rejected"), "{err}"); let t = token(&owner, &gw.did, "dev.knotless.pushRefs"); assert_eq!(post(&push, Some(&t), header(json!([])), false).0, 403); // Nobody waits on a delegation nobody started, nor takes one back. let t = token(&owner, &gw.did, "dev.knotless.getDelegation"); let res = reqwest::blocking::Client::new() .get(format!( "{}/xrpc/dev.knotless.getDelegation?state=nope", gw.base )) .bearer_auth(&t) .send() .unwrap(); assert_eq!(res.status().as_u16(), 404); let res = reqwest::blocking::get(format!("{}/oauth/callback?state=nope&code=x", gw.base)).unwrap(); assert_eq!(res.status().as_u16(), 400); // Started, and the browser comes back with a code that is no good. let t = token(&owner, &gw.did, "dev.knotless.putDelegation"); let (status, body) = post(&delegate, Some(&t), vec![], false); assert_eq!(status, 200, "{body}"); let state = serde_json::from_str::(&body).unwrap()["state"] .as_str() .unwrap() .to_string(); let issuer = net().pds.clone(); let page = reqwest::blocking::get(format!( "{}/oauth/callback?state={state}&code=bogus&iss={issuer}", gw.base )) .unwrap() .text() .unwrap(); assert!(page.contains("was not delegated"), "{page}"); // The metadata the PDS reads the gateway's client from. let metadata: Value = reqwest::blocking::get(format!("{}/oauth-client-metadata.json", gw.base)) .unwrap() .json() .unwrap(); assert!( metadata["client_id"] .as_str() .unwrap() .starts_with("https://") ); assert_eq!(metadata["jwks"]["keys"].as_array().unwrap().len(), 1); assert_eq!( owner .cli( &["account", "delegate", &owner.account.handle, &gw.did], b"" ) .0, 0 ); // Nothing answers for this one, and that one is no gateway. let (code, _) = owner.cli( &[ "account", "delegate", &owner.account.handle, "did:web:localhost%3A1", ], b"", ); assert_eq!(code, 1); let (code, _) = owner.cli( &["delegate", &owner.account.handle, &owner.account.did], b"", ); assert_eq!(code, 1); // Malformed pushes. let t = token(&owner, &gw.did, "dev.knotless.pushRefs"); assert_eq!(post(&push, Some(&t), b"no header".to_vec(), false).0, 400); let bad = header(json!([{"dst": "refs/heads/x", "src": "main"}])); assert_eq!(post(&push, Some(&t), bad, false).0, 400); let bad = header(json!([{"dst": "HEAD", "src": null}])); assert_eq!(post(&push, Some(&t), bad, false).0, 400); let mut garbage = header(json!([])); garbage.extend(b"PACK not really"); assert_eq!(post(&push, Some(&t), garbage, false).0, 400); let absent = header(json!([{"dst": "refs/heads/x", "src": "1".repeat(40)}])); let (status, body) = post(&push, Some(&t), absent, false); assert_eq!(status, 400); assert!(body.contains("does not hold"), "{body}"); let missing = json!({"repo": owner.account.did, "name": "nothing-here", "updates": []}); let mut body = missing.to_string().into_bytes(); body.push(b'\n'); let (status, text) = post(&push, Some(&t), body, false); assert_eq!(status, 404, "{text}"); let mut body = json!({"repo": owner.account.did, "name": "bad name!", "updates": []}) .to_string() .into_bytes(); body.push(b'\n'); assert_eq!(post(&push, Some(&t), body, false).0, 400); // Malformed commits. let t = token(&owner, &gw.did, "dev.knotless.createCommit"); let empty = json!({"repo": owner.account.did, "name": "guarded", "branch": "", "message": "m", "changes": []}); assert_eq!( post(&commit, Some(&t), empty.to_string().into_bytes(), true).0, 400 ); // One that changes nothing lands nothing. let noop = json!({"repo": owner.account.did, "name": "guarded", "branch": "main", "message": "m", "changes": []}); let (status, body) = post(&commit, Some(&t), noop.to_string().into_bytes(), true); assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["changed"], false ); // An edit is made in the file as the branch holds it; one whose text // is no longer there fails on its own. let edit = json!({"repo": owner.account.did, "name": "guarded", "branch": "main", "message": "e", "changes": [{"path": "f", "edits": [{"old": "w 0", "new": "w edited"}]}]}); let (status, body) = post(&commit, Some(&t), edit.to_string().into_bytes(), true); assert_eq!(status, 200, "{body}"); let (status, body) = post(&commit, Some(&t), edit.to_string().into_bytes(), true); assert_eq!(status, 409, "{body}"); assert!( body.contains("edit 1 of f: old is not in the file"), "{body}" ); // Archived, it takes nothing. assert_eq!(owner.cli(&["repo", "archive", &url], b"").0, 0); let (status, body) = post(&commit, Some(&t), noop.to_string().into_bytes(), true); assert_eq!(status, 403, "{body}"); assert!(body.contains("guarded is archived"), "{body}"); assert_eq!(owner.cli(&["repo", "archive", &url, "--undo"], b"").0, 0); // The DID document says where to find it. let doc: Value = reqwest::blocking::get(format!("{}/.well-known/did.json", gw.base)) .unwrap() .json() .unwrap(); assert_eq!(doc["id"], gw.did); assert_eq!(doc["service"][0]["serviceEndpoint"], gw.base); assert!(Gateway::new("ftp://x").is_err()); assert!(Gateway::new("https://").is_err()); assert!(Gateway::new("https://a/b").is_err()); assert_eq!( Gateway::new("https://knot.example/").unwrap().did, "did:web:knot.example" ); } /// A runner the repository names that may not push lands its merge queue /// through the gateway the owner delegated to, and pushes nothing else: /// each commit a pull queued squashed onto the one before, and those /// pulls' branches deleted. What it marks merged counts. #[test] fn runners_land_the_merge_queue_through_a_gateway() { let gw = gateway_with(None, true); let mut owner = person(account()); let mut runner = person(account()); owner.set("KNOTLESS_APPVIEW", &gw.base); runner.set("KNOTLESS_APPVIEW", &gw.base); let ok = |s: &Sandbox, args: &[&str]| { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out.trim().to_string() }; let url = owner.url("queued"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); ok(&owner, &["runner", "add", &url, &runner.account.handle]); ok( &owner, &["account", "delegate", &owner.account.handle, &gw.did], ); ok( &owner, &["branch", "protect", &url, "main", "--merges", "--queue"], ); for b in ["a", "b", "other"] { owner.git(&work, &["checkout", "-q", "-B", b, "main"]); owner.commit_file(&work, b, &format!("{b}\n")); owner.git(&work, &["push", "-q", &url, b]); } let a = ok(&owner, &["pull", "create", &url, "main", &url, "a", "a"]); let b = ok(&owner, &["pull", "create", &url, "main", &url, "b", "b"]); for pull in [&a, &b] { ok(&owner, &["pull", "merge", &url, pull, "--queue"]); } wait_until(|| { let line = owner.cli(&["queue", "list", &url], b"").1; line.contains(&a) && line.contains(&b) }); // Anything else it pushes is refused. let clone = runner.path("clone"); runner.git(runner.dir.path(), &["clone", "-q", &url, "clone"]); runner.git(&clone, &["fetch", "-q", "origin"]); let refuses = |refspecs: &[&str], says: &str| { let mut args = vec!["push", "-q", "origin"]; args.extend(refspecs); let (pushed, _, err) = runner.git_raw(&clone, &args); assert!(!pushed && err.contains(says), "{refspecs:?}: {err}"); }; runner.commit_file(&clone, "x", "x\n"); refuses(&["HEAD:main"], "is no pull queued into main squashed"); refuses( &["HEAD:refs/heads/fresh"], "there is no branch fresh to land on", ); refuses(&["+origin/a:main"], "one branch moved forward"); refuses(&[":refs/heads/other"], "one branch moved forward"); runner.git(&clone, &["checkout", "-q", "-B", "m", "origin/main"]); runner.git(&clone, &["merge", "-q", "--no-ff", "origin/a", "-m", "m"]); refuses(&["m:main"], "is not one commit on the one before"); let squash = |onto: &str, head: &str| { let merged = runner.git(&clone, &["merge-tree", "--write-tree", onto, head]); let tree = merged.lines().next().unwrap().to_string(); runner.git(&clone, &["commit-tree", &tree, "-p", onto, "-m", head]) }; let landed = squash("origin/main", "origin/a"); let main = format!("{landed}:refs/heads/main"); refuses( &[&main, ":refs/heads/other"], "other is not the branch of a pull landed", ); // Nor does it commit, nor move a branch to where it is. let at = runner.git(&clone, &["rev-parse", "origin/main"]); let header = json!({"repo": owner.account.did, "name": "queued", "updates": [{"dst": "refs/heads/main", "src": at}]}); let mut body = header.to_string().into_bytes(); body.push(b'\n'); let t = token(&runner, &gw.did, "dev.knotless.pushRefs"); let push = format!("{}/xrpc/dev.knotless.pushRefs", gw.base); let (status, said) = post(&push, Some(&t), body, false); assert_eq!(status, 403, "{said}"); assert!(said.contains("is no batch on main"), "{said}"); let t = token(&runner, &gw.did, "dev.knotless.createCommit"); let commit = json!({"repo": owner.account.did, "name": "queued", "branch": "main", "message": "x", "changes": [{"path": "x", "data": {"$bytes": "eA"}}]}); let commit_url = format!("{}/xrpc/dev.knotless.createCommit", gw.base); let (status, said) = post(&commit_url, Some(&t), commit.to_string().into_bytes(), true); assert_eq!(status, 403, "{said}"); assert!(said.contains("is not a collaborator"), "{said}"); // The batch lands, as the merge queue lands it, committed as a runner. let mut ctx = ctx(&runner); ctx.env.signer = knotless::env::Signer::Runner; let repo = knotless::url::RepoUrl::parse(&url).unwrap(); tokio::runtime::Runtime::new().unwrap().block_on(async { let t = knotless::forge::Target::open(&ctx, &repo).await.unwrap(); let entries = t.queue_entries(&ctx).await.unwrap(); let items = knotless::forge::assemble(&t.record, t.remote.did(), &entries); let key = t.reference().key; let mirror = knotless::mirror::Mirror::open(&ctx, t.remote.did(), &key) .await .unwrap(); mirror.sync(&t.remote, &t.snap).await.unwrap(); let mut pulls = Vec::new(); for item in knotless::queue::queued(&items) { let head = item.head.as_ref().unwrap(); let at = knotless::forge::head_of(&ctx, &mirror.git, head).await; pulls.push((item.clone(), at.unwrap().unwrap())); } let me = knotless::session::me(&ctx).await.unwrap(); let tip = t.snap.find_ref("refs/heads/main").unwrap(); let base = tip.value.target.clone().unwrap(); let stack = knotless::queue::stack(&ctx, &mirror.git, &base, &pulls, &me) .await .unwrap(); assert_eq!(stack.stacked.len(), 2); // Its author wrote it, and knotless, as the runner's account, made it. let made = [ "log", "-1", "--format=%an|%cn|%ce", &stack.stacked[1].commit, ]; let made = mirror.git.text(&made, None).await.unwrap(); assert_eq!(made, format!("Test|knotless|{}@knotless.invalid", me.did)); knotless::queue::land(&ctx, &repo, &mirror.git, "main", &stack) .await .unwrap(); // Each pull's merge names the batch's tip, whose checks let it land. let runner_url = knotless::url::RepoUrl::parse(&format!("knotless://{}/_", me.did)); let mine = knotless::remote::Remote::connect(&ctx, &runner_url.unwrap()) .await .unwrap(); let states = mine .list_all::(knotless::records::nsid::STATE) .await .unwrap(); let batches: Vec<_> = states .iter() .filter(|s| s.value.state.as_deref() == Some("merged")) .map(|s| s.value.batch.as_deref()) .collect(); assert_eq!(batches, [Some(stack.tip()); 2]); }); let refs = ok(&owner, &["repo", "refs", &url]); let has = |branch: &str| { refs.lines() .any(|l| l.ends_with(&format!("\trefs/heads/{branch}"))) }; assert!(!has("a") && !has("b") && has("other"), "{refs}"); for pull in [&a, &b] { wait_until(|| ok(&owner, &["pull", "show", &url, pull]).contains("merged pull")); } // A pull set to merge when its checks pass, into a branch with no // queue, it merges as a merge does; nothing else onto that branch. for (branch, from) in [("dev", "main"), ("c", "dev")] { owner.git(&work, &["checkout", "-q", "-B", branch, from]); owner.git(&work, &["push", "-q", &url, branch]); } owner.commit_file(&work, "c", "c\n"); owner.git(&work, &["push", "-q", &url, "c"]); let c = ok(&owner, &["pull", "create", &url, "dev", &url, "c", "c"]); wait_until(|| owner.cli(&["pull", "merge", &url, &c, "--auto"], b"").0 == 0); // Another into dev was set and cancelled: it lets nothing through. owner.git(&work, &["checkout", "-q", "-B", "d", "dev"]); owner.commit_file(&work, "d", "d\n"); owner.git(&work, &["push", "-q", &url, "d"]); let d = ok(&owner, &["pull", "create", &url, "dev", &url, "d", "d"]); wait_until(|| owner.cli(&["pull", "merge", &url, &d, "--auto"], b"").0 == 0); ok(&owner, &["pull", "merge", &url, &d, "--auto", "--undo"]); runner.git(&clone, &["fetch", "-q", "origin"]); runner.git(&clone, &["checkout", "-q", "-B", "n", "origin/dev"]); runner.commit_file(&clone, "n", "n\n"); refuses(&["n:dev"], "is no pull queued into dev squashed"); let merging = knotless::forge::merge(&ctx, &repo, &c, true, Default::default()); tokio::runtime::Runtime::new() .unwrap() .block_on(merging) .unwrap(); wait_until(|| ok(&owner, &["pull", "show", &url, &c]).contains("merged pull")); } /// Lands, as `runner` does in the merge queue, the pulls queued in the /// repository at `url`, through the gateway: each squashed onto the one /// before, and the batch landed. Answers how many it landed. fn land_queued(runner: &Sandbox, url: &str) -> usize { let ctx = ctx(runner); let repo = knotless::url::RepoUrl::parse(url).unwrap(); tokio::runtime::Runtime::new().unwrap().block_on(async { let t = knotless::forge::Target::open(&ctx, &repo).await.unwrap(); let entries = t.queue_entries(&ctx).await.unwrap(); let items = knotless::forge::assemble(&t.record, t.remote.did(), &entries); let key = t.reference().key; let mirror = knotless::mirror::Mirror::open(&ctx, t.remote.did(), &key) .await .unwrap(); mirror.sync(&t.remote, &t.snap).await.unwrap(); let mut pulls = Vec::new(); for item in knotless::queue::queued(&items) { let head = item.head.as_ref().unwrap(); let at = knotless::forge::head_of(&ctx, &mirror.git, head).await; pulls.push((item.clone(), at.unwrap().unwrap())); } let me = knotless::session::me(&ctx).await.unwrap(); let tip = t.snap.find_ref("refs/heads/main").unwrap(); let base = tip.value.target.clone().unwrap(); let stack = knotless::queue::stack(&ctx, &mirror.git, &base, &pulls, &me) .await .unwrap(); knotless::queue::land(&ctx, &repo, &mirror.git, "main", &stack) .await .unwrap(); stack.stacked.len() }) } /// A private repository's runner that may not push lands its merge queue /// through the gateway its owner delegated to, which reads the queue in /// the repository's space as the owner, nobody being logged in there. /// These need a PDS on the spaces alpha, and pass trivially without one. #[test] fn runners_land_a_private_merge_queue_through_a_gateway() { if !net().spaces { eprintln!("skipped: the PDS is not on the spaces alpha"); return; } let gw = gateway_with(None, true); let [mut owner, mut runner] = people(); owner.set("KNOTLESS_APPVIEW", &gw.base); runner.set("KNOTLESS_APPVIEW", &gw.base); let ok = |s: &Sandbox, args: &[&str]| { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out.trim().to_string() }; let url = owner.url("hushed"); let handle = runner.account.handle.clone(); ok( &owner, &["repo", "create", &url, "--private", "--runner", &handle], ); ok( &owner, &["account", "delegate", &owner.account.handle, &gw.did], ); ok( &owner, &["branch", "protect", &url, "main", "--merges", "--queue"], ); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); for b in ["a", "b"] { owner.git(&work, &["checkout", "-q", "-B", b, "main"]); owner.commit_file(&work, b, &format!("{b}\n")); owner.git(&work, &["push", "-q", &url, b]); } let a = ok(&owner, &["pull", "create", &url, "main", &url, "a", "a"]); let b = ok(&owner, &["pull", "create", &url, "main", &url, "b", "b"]); for pull in [&a, &b] { ok(&owner, &["pull", "merge", &url, pull, "--queue"]); } wait_until(|| { let line = owner.cli(&["queue", "list", &url], b"").1; line.contains(&a) && line.contains(&b) }); assert_eq!(land_queued(&runner, &url), 2); let refs = ok(&owner, &["repo", "refs", &url]); let has = |branch: &str| { refs.lines() .any(|l| l.ends_with(&format!("\trefs/heads/{branch}"))) }; assert!(!has("a") && !has("b") && has("main"), "{refs}"); for pull in [&a, &b] { wait_until(|| ok(&owner, &["pull", "show", &url, pull]).contains("merged pull")); } } #[test] fn a_server_that_is_not_a_gateway() { let s = Sandbox::new(); let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let router = router(App::new(ctx(&s), Duration::ZERO)); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); let res = reqwest::blocking::get(format!("{base}/.well-known/did.json")).unwrap(); assert_eq!(res.status().as_u16(), 404); let (status, _) = post( &format!("{base}/xrpc/dev.knotless.pushRefs"), Some("x"), vec![], false, ); assert_eq!(status, 404); // Nor has it a DID for a query's service token to be for. let res = reqwest::blocking::Client::new() .get(format!("{base}/xrpc/dev.knotless.getProfile?actor=x")) .bearer_auth("x") .send() .unwrap(); assert_eq!(res.status().as_u16(), 401); } /// A query knows who asks it from a service token their PDS signed for the /// server's DID and that query, as atproto's appviews do: sent by an app /// signed in to the PDS, or by the PDS proxying to the `#knotless_appview` /// service. A token that does not hold is refused; with none, a query /// answers as for anyone. #[test] fn queries_know_who_asks() { let server = gateway(); let viewer = person(account()); let did = viewer.account.did.clone(); let doc: Value = reqwest::blocking::get(format!("{}/.well-known/did.json", server.base)) .unwrap() .json() .unwrap(); let services = doc["service"].as_array().unwrap(); let appview = services.iter().find(|s| s["id"] == "#knotless_appview"); assert_eq!(appview.unwrap()["serviceEndpoint"], server.base); let get = |base: &str, token: Option<&str>| { let mut req = reqwest::blocking::Client::new() .get(format!("{base}/xrpc/dev.knotless.getProfile?actor={did}")); if let Some(t) = token { req = req.bearer_auth(t); } req.send().unwrap() }; let anyone = get(&server.base, None); assert_eq!(anyone.status().as_u16(), 200); assert_eq!(anyone.headers()["vary"], "authorization"); let signed = token(&viewer, &server.did, "dev.knotless.getProfile"); let theirs = get(&server.base, Some(&signed)); assert_eq!(theirs.status().as_u16(), 200); assert_eq!(theirs.headers()["cache-control"], "private, no-cache"); assert_eq!(theirs.headers()["vary"], "authorization"); let other_method = token(&viewer, &server.did, "dev.knotless.getRepo"); let other_server = token( &viewer, "did:web:elsewhere.example", "dev.knotless.getProfile", ); for wrong in [other_method.as_str(), other_server.as_str(), "x"] { let res = get(&server.base, Some(wrong)); assert_eq!(res.status().as_u16(), 401); let failure: Value = res.json().unwrap(); assert_eq!(failure["error"], "AuthenticationRequired"); } // What the query is handed: the account, or no one. let echo = axum::Router::new() .route( "/xrpc/dev.knotless.getProfile", axum::routing::get( |axum::Extension(v): axum::Extension| async move { v.0.unwrap_or_default() }, ), ) .route_layer(axum::middleware::from_fn_with_state( server.app.clone(), knotless_serve::viewer::viewer, )) .with_state(server.app.clone()); let listener = server ._rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let echoed = format!("http://{}", listener.local_addr().unwrap()); server ._rt .spawn(async move { axum::serve(listener, echo).await.unwrap() }); assert_eq!(get(&echoed, Some(&signed)).text().unwrap(), did); assert_eq!(get(&echoed, None).text().unwrap(), ""); } /// A bot logs in through the gateway, as the account KNOTLESS_BOT_DIDS /// names, and keeps the session in the bots' directory: one of the /// gateway's client, which reads private repositories as an app password /// cannot, and refreshes with the key kept beside it. #[test] fn bots_log_in_through_the_gateway() { let bot = person(account()); let url = bot.url("watched"); if net().spaces { assert_eq!(bot.cli(&["repo", "create", &url, "--private"], b"").0, 0); } let work = bot.work("w", 1); bot.git(&work, &["push", "-q", &url, "main"]); let sha = bot.git(&work, &["rev-parse", "HEAD"]); let get = |url: &str| { let res = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap() .get(url) .send() .unwrap(); let to = res .headers() .get("location") .map(|l| l.to_str().unwrap().to_string()); (res.status().as_u16(), to, res.text().unwrap()) }; // A gateway that keeps no bots' sessions has no client for them. let plain = gateway(); let (status, _, _) = get(&format!("{}/oauth/bot-client-metadata.json", plain.base)); assert_eq!(status, 404); let gw = gateway_for(Some(&bot.account)); let (status, _, metadata) = get(&format!("{}/oauth/bot-client-metadata.json", gw.base)); assert_eq!(status, 200); let metadata: Value = serde_json::from_str(&metadata).unwrap(); assert!( metadata["scope"].as_str().unwrap().contains("space:"), "{metadata}" ); assert!( metadata["redirect_uris"][0] .as_str() .unwrap() .ends_with("/oauth/bot-callback") ); // Only its own bots, and only logins it started. let stranger = account(); let (status, _, _) = get(&format!( "{}/oauth/bot?account={}", gw.base, stranger.handle )); assert_eq!(status, 403); let (status, _, _) = get(&format!("{}/oauth/bot-callback?state=nope&code=x", gw.base)); assert_eq!(status, 400); // A login started and never finished waits, until abandoned. let started = format!("{}/oauth/bot?account={}", gw.base, bot.account.handle); assert_eq!(get(&started).0, 303); // The operator approves, as the bot's account, in a browser: once // refused, which keeps nothing, then approved. let approve = |extra: &str| { let (status, to, _) = get(&format!( "{}/oauth/bot?account={}", gw.base, bot.account.handle )); assert_eq!(status, 303); let command = format!("{} {}", browser(&bot.account, extra), to.unwrap()); let mut words = command.split_whitespace(); let out = std::process::Command::new(words.next().unwrap()) .args(words) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); }; approve("--reject"); assert!(!gw.bots.join("sessions").exists()); approve(""); // The bot, with no password, acts from the bots' directory. let mut runner = web(Sandbox::with(bot.account.clone())); runner.set("KNOTLESS_PASSWORD", ""); runner.set("KNOTLESS_CONFIG_DIR", gw.bots.to_str().unwrap()); let stored = knotless::session::load(&ctx(&runner), &bot.account.did).unwrap(); assert_eq!(stored.handle, bot.account.handle); let oauth = stored.oauth.clone().unwrap(); assert!( oauth .client_id .unwrap() .ends_with("/oauth/bot-client-metadata.json") ); let (_, refs) = runner.cli(&["repo", "refs", &url], b""); assert_eq!(refs, format!("{sha}\trefs/heads/main")); // And refreshes, signing as the gateway's client. let mut expired = stored.clone(); expired.oauth.as_mut().unwrap().expires_at = 0; let bots = ctx(&runner); knotless::session::save(&bots, &expired).unwrap(); let fresh = tokio::runtime::Runtime::new().unwrap().block_on(async { let id = knotless::identity::resolve(&bots, &bot.account.did) .await .unwrap(); knotless::session::ensure(&bots, &id).await.unwrap() }); assert_ne!(fresh.refresh_jwt, stored.refresh_jwt); } /// A request to `base` as a browser on the site sends it: with `cookie`, /// if any, and from the page at `origin`, if any. Its status, the cookies /// it sets, and its answer. fn browse( method: &str, url: &str, cookie: Option<&str>, origin: Option<&str>, body: Option, ) -> (u16, Vec, Value) { let client = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); let mut req = client.request(method.parse().unwrap(), url); if let Some(cookie) = cookie { req = req.header("cookie", cookie); } if let Some(origin) = origin { req = req.header("origin", origin); } if let Some(body) = body { req = req.json(&body); } let res = req.send().unwrap(); let status = res.status().as_u16(); let set = res.headers().get_all("set-cookie").iter(); let set = set.map(|c| c.to_str().unwrap().to_string()).collect(); let answer = res.json().unwrap_or(Value::Null); (status, set, answer) } /// The `name=value` a set cookie holds. fn pair(set: &str) -> &str { set.split(';').next().unwrap() } /// Logs `who` in to the pages of `server`, approving (or with `extra` /// `--reject`, refusing) in a browser: the cookies the site set on the /// way back, the session's among them once approved. fn log_in(server: &Server, who: &Account, extra: &str) -> Vec { log_in_beside(server, who, extra, "") } /// [`log_in`], in a browser that holds `held`, its cookies `;` between. fn log_in_beside(server: &Server, who: &Account, extra: &str, held: &str) -> Vec { let start = json!({"handle": who.handle, "back": "/somewhere/else"}); let login = format!("{}/oauth/login", server.base); let (status, set, answer) = browse("POST", &login, None, None, Some(start)); assert_eq!(status, 200, "{answer}"); let started = pair(&set[0]).to_string(); assert!(started.starts_with("knotless_login="), "{started}"); let started = [started.as_str(), held].join(";"); let url = answer["url"].as_str().unwrap(); let command = format!("{} --cookie {started} {url}", browser(who, extra)); let mut words = command.split_whitespace(); let out = std::process::Command::new(words.next().unwrap()) .args(words) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8(out.stdout) .unwrap() .lines() .map(str::to_string) .collect() } /// People log in to the pages, and star repositories there, as the /// command line does: the server keeps their session, apart from what /// owners delegate, and the browser holds a cookie naming them. #[test] fn logging_in_to_the_pages_and_starring() { // Started first, so its index sees the repositories made after. let server = gateway_with(None, true); let owner = person(account()); let liked = owner.url("liked"); let work = owner.work("w", 1); owner.commit_file(&work, "main.rs", "fn main() {}\n"); owner.git(&work, &["push", "-q", &liked, "main"]); let mut fan = person(account()); assert_eq!(fan.cli(&["repo", "fork", &liked, "liked-too"], b"").0, 0); let third = person(account()); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let viewer = |cookie: Option<&str>| browse("GET", &xrpc("getViewer"), cookie, None, None).2; // The pages' own client, asking for all knotless asks for. let metadata = format!("{}/oauth/web-client-metadata.json", server.base); let (status, _, metadata) = browse("GET", &metadata, None, None, None); assert_eq!(status, 200); assert!(metadata["scope"].as_str().unwrap().contains("space:")); let back = metadata["redirect_uris"][0].as_str().unwrap(); assert!(back.ends_with("/oauth/web-callback"), "{back}"); assert_eq!(viewer(None), json!({"login": true})); // No account, another site's page, or a login this browser did not // start, logs no one in. let login = format!("{}/oauth/login", server.base); let nobody = json!({"handle": "nobody.invalid"}); assert_eq!(browse("POST", &login, None, None, Some(nobody)).0, 400); let them = json!({"handle": fan.account.handle}); let away = Some("https://evil.example"); assert_eq!(browse("POST", &login, None, away, Some(them)).0, 403); let callback = format!("{}/oauth/web-callback?state=nope&code=x", server.base); let stray = Some("knotless_login=nope"); assert_eq!(browse("GET", &callback, None, None, None).0, 400); assert_eq!(browse("GET", &callback, stray, None, None).0, 400); // Refused at the PDS, it goes back logged in as no one. let refused = log_in(&server, &fan.account, "--reject"); assert!(refused.iter().all(|c| !c.starts_with("knotless_session="))); let set = log_in(&server, &fan.account, ""); let session = set .iter() .find(|c| c.starts_with("knotless_session=")) .unwrap(); assert!(session.contains("HttpOnly") && session.contains("SameSite=Lax")); let cookie = pair(session).to_string(); let me = Some(cookie.as_str()); let shown = viewer(me); assert_eq!(shown["actor"]["did"], fan.account.did); assert_eq!(shown["actor"]["handle"], fan.account.handle); // Its session is the pages', not a delegation to the gateway. let pages = server._sandbox.path("config/web"); assert!(pages.exists(), "{}", pages.display()); let delegated = format!("config/sessions/{}.json", fan.account.did.replace(':', "_")); assert!(!server._sandbox.path(&delegated).exists()); // Starring, as the account logged in. let repo = format!("at://{}/dev.knotless.repo/liked", owner.account.did); let get_repo = |cookie: Option<&str>| { let url = format!("{}?repo={repo}", xrpc("getRepo")); browse("GET", &url, cookie, None, None).2 }; assert_eq!(get_repo(me)["starCount"], 0); assert_eq!(get_repo(me)["viewer"], json!({})); let star = |nsid: &str, cookie: Option<&str>, origin: Option<&str>, repo: &str| { browse( "POST", &xrpc(nsid), cookie, origin, Some(json!({"repo": repo})), ) }; let (status, _, starred) = star("starRepo", me, Some(&server.base), &repo); assert_eq!(status, 200, "{starred}"); assert_eq!(starred["starCount"], 1); let uri = starred["star"].as_str().unwrap().to_string(); assert!(uri.starts_with(&format!("at://{}/dev.knotless.star/", fan.account.did))); let records = fan.records("dev.knotless.star"); assert_eq!(records.len(), 1); assert_eq!(records[0].1["subject"]["uri"], repo); // Again, the star there is. assert_eq!(star("starRepo", me, None, &repo).2["star"], uri.as_str()); assert_eq!(get_repo(me)["starCount"], 1); assert_eq!(get_repo(me)["viewer"]["star"], uri.as_str()); assert_eq!(get_repo(None)["starCount"], 1); assert_eq!(get_repo(None).get("viewer"), None); let theirs = format!("at://{}/dev.knotless.repo/liked-too", fan.account.did); assert_eq!(star("starRepo", me, None, &theirs).0, 200); // Only from this site's pages, as someone logged in to them, and only // a repository. assert_eq!(star("starRepo", None, None, &repo).0, 401); // A service token names someone, even one logged in here, but writes // nothing with their session. let service = token(&fan, &server.did, "dev.knotless.starRepo"); let body = serde_json::to_vec(&json!({"repo": repo})).unwrap(); assert_eq!(post(&xrpc("starRepo"), Some(&service), body, true).0, 401); assert_eq!(star("starRepo", me, away, &repo).0, 403); assert_eq!(star("starRepo", me, None, "at://nope").0, 400); if net().spaces { let hidden = owner.url("hidden"); assert_eq!( owner.cli(&["repo", "create", &hidden, "--private"], b"").0, 0 ); let private = format!("at://{}/dev.knotless.repo/hidden", owner.account.did); assert_eq!(star("starRepo", me, None, &private).0, 404); } // What the account starred, as the index has it, the latest first. let stars = format!("{}?actor={}", xrpc("getActorStars"), fan.account.handle); wait_until(|| { browse("GET", &stars, None, None, None).2["repos"] .as_array() .map_or(0, Vec::len) == 2 }); let listed = browse("GET", &stars, None, None, None).2; assert_eq!(listed["repos"][0]["name"], "liked-too"); assert_eq!(listed["repos"][0]["forkOf"]["name"], "liked"); assert_eq!(listed["repos"][1]["owner"]["did"], owner.account.did); // Each with its stars and forks, and what it is written in: counted // in the background as a list first asks, never waited on. let spoken = || browse("GET", &stars, None, None, None).2["repos"][1]["language"]["name"].clone(); wait_until(|| spoken() == "Rust"); // And counted again as its default branch moves. owner.commit_file(&work, "main.py", &"print()\n".repeat(100)); owner.git(&work, &["push", "-q", &liked, "main"]); wait_until(|| spoken() == "Python"); assert_eq!(listed["repos"][1]["starCount"], 1); assert_eq!(listed["repos"][1]["forkCount"], 1); assert_eq!(listed["repos"][0]["forkCount"], 0); let unknown = format!("{}?actor=nobody.invalid", xrpc("getActorStars")); assert_eq!(browse("GET", &unknown, None, None, None).0, 400); let plain = gateway(); let unindexed = format!( "{}/xrpc/dev.knotless.getActorStars?actor={}", plain.base, fan.account.did ); assert_eq!(browse("GET", &unindexed, None, None, None).0, 404); // Unstarring takes every star of theirs naming it back. let (status, _, unstarred) = star("unstarRepo", me, None, &repo); assert_eq!(status, 200, "{unstarred}"); assert_eq!(unstarred, json!({"starCount": 0})); assert_eq!( star("unstarRepo", me, None, &repo).2, json!({"starCount": 0}) ); assert_eq!(fan.records("dev.knotless.star").len(), 1); // Following, as the account logged in. let profile = |of: &str, cookie: Option<&str>| { let url = format!("{}?actor={of}", xrpc("getProfile")); browse("GET", &url, cookie, None, None).2 }; let them = owner.account.did.as_str(); assert_eq!(profile(them, me)["followersCount"], 0); assert_eq!(profile(them, me)["viewer"], json!({})); let follow = |nsid: &str, cookie: Option<&str>, subject: &str| { let body = Some(json!({ "subject": subject })); browse("POST", &xrpc(nsid), cookie, None, body) }; let (status, _, followed) = follow("followActor", me, them); assert_eq!(status, 200, "{followed}"); assert_eq!(followed["followersCount"], 1); let uri = followed["following"].as_str().unwrap().to_string(); assert!(uri.starts_with(&format!("at://{}/dev.knotless.follow/", fan.account.did))); // Again, the follow there is. assert_eq!(follow("followActor", me, them).2["following"], uri.as_str()); assert_eq!(fan.records("dev.knotless.follow").len(), 1); assert_eq!(profile(them, me)["viewer"]["following"], uri.as_str()); assert_eq!(profile(them, None)["followersCount"], 1); assert_eq!(profile(them, None).get("viewer"), None); assert_eq!(profile(&fan.account.did, None)["followsCount"], 1); assert_eq!(follow("followActor", me, &third.account.did).0, 200); // Not itself, nor no one, and only as someone logged in. assert_eq!(follow("followActor", me, &fan.account.did).0, 400); assert_eq!(follow("followActor", me, "nobody.invalid").0, 400); assert_eq!(follow("followActor", None, them).0, 401); // Whom it follows, a page at a time, the latest first; and who // follows whom. let follows = |cursor: &str| { let url = format!( "{}?actor={}&limit=1{cursor}", xrpc("getFollows"), fan.account.handle ); browse("GET", &url, None, None, None).2 }; let first = follows(""); assert_eq!(first["actors"][0]["did"], third.account.did); let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20"); let next = follows(&format!("&cursor={cursor}")); assert_eq!(next["actors"][0]["did"], owner.account.did); assert_eq!(next.get("cursor"), None); let followers = format!("{}?actor={them}", xrpc("getFollowers")); let followers = browse("GET", &followers, None, None, None).2; assert_eq!(followers["actors"][0]["handle"], fan.account.handle); let unknown = format!("{}?actor=nobody.invalid", xrpc("getFollowers")); assert_eq!(browse("GET", &unknown, None, None, None).0, 400); let unindexed = format!("{}/xrpc/dev.knotless.getFollows?actor={them}", plain.base); assert_eq!(browse("GET", &unindexed, None, None, None).0, 404); // The command line asks the appview who follows an account. fan.set("KNOTLESS_APPVIEW", &server.base); let (code, out) = fan.cli(&["account", "followers", &owner.account.handle], b""); assert_eq!((code, out), (0, fan.account.handle.clone())); // The feed of whom it follows: what they and it did, its star among // that, and what is done on its repositories; and no one else's. Of // everyone's, no stars; and only someone logged in follows anyone. let feed = |query: &str, cookie: Option<&str>| { let url = format!("{}?limit=100{query}", xrpc("getFeed")); browse("GET", &url, cookie, None, None) }; let events = |answer: &Value| answer["events"].as_array().unwrap().clone(); let stars = |answer: &Value| { let found = events(answer).into_iter(); let found = found.filter(|e| e["action"]["$type"] == "dev.knotless.getFeed#starred"); found .map(|e| e["action"]["subject"].clone()) .collect::>() }; wait_until(|| stars(&feed("&filter=following", me).2).contains(&json!(theirs))); let (status, _, followed) = feed("&filter=following", me); assert_eq!(status, 200, "{followed}"); let ours = [&fan.account.did, &owner.account.did, &third.account.did]; for e in events(&followed) { let (who, whose) = (&e["actor"]["did"], &e["repo"]["did"]); let known = ours.iter().any(|did| who == did.as_str()); assert!(known || whose == fan.account.did.as_str(), "{e}"); } let by = |did: &str| events(&followed).iter().any(|e| e["actor"]["did"] == did); assert!(by(&owner.account.did), "{followed}"); // A page of it cut short, its star among what is cut, goes on after. let url = format!("{}?limit=1&filter=following", xrpc("getFeed")); let (status, _, one) = browse("GET", &url, me, None, None); assert_eq!(status, 200, "{one}"); assert!( events(&one).len() <= 1 && one["cursor"].is_string(), "{one}" ); let theirs_alone = format!("&actor={}", fan.account.did); assert!(stars(&feed(&theirs_alone, None).2).contains(&json!(theirs))); assert_eq!(stars(&feed("", me).2), Vec::::new()); // Its stars alone, of whom it follows, are stars and nothing else. let starred = feed("&filter=following&kind=stars", me).2; assert!(stars(&starred).contains(&json!(theirs)), "{starred}"); assert_eq!(stars(&starred).len(), events(&starred).len(), "{starred}"); assert_eq!(feed("&filter=following", None).0, 401); // Unfollowing takes every follow of theirs naming it back. assert_eq!( follow("unfollowActor", me, them).2, json!({"followersCount": 0}) ); assert_eq!(fan.records("dev.knotless.follow").len(), 1); // What others did, for the account asking: the one it follows hears of // it, until it says it saw it. let asked = |nsid: &str, of: &Sandbox, at: &Server, query: &str| { let url = format!("{}/xrpc/dev.knotless.{nsid}{query}", at.base); let token = token(of, &at.did, &format!("dev.knotless.{nsid}")); let res = reqwest::blocking::Client::new() .get(url) .bearer_auth(token) .send() .unwrap(); (res.status().as_u16(), res.json::().unwrap()) }; let (status, heard) = asked("listNotifications", &third, &server, "?limit=10"); assert_eq!(status, 200, "{heard}"); let first = &heard["notifications"][0]; assert_eq!( (&first["reason"], &first["isRead"]), (&json!("follow"), &json!(false)) ); assert_eq!(first["author"]["did"], fan.account.did); assert_eq!(heard.get("seenAt"), None); let unread = asked("getUnreadCount", &third, &server, ""); assert_eq!(unread, (200, json!({"count": 1}))); let at = first["createdAt"].as_str().unwrap().to_string(); let seen = |at: &str, token: Option<&str>| { let body = serde_json::to_vec(&json!({ "seenAt": at })).unwrap(); post(&xrpc("updateSeen"), token, body, true) }; let saw = token(&third, &server.did, "dev.knotless.updateSeen"); assert_eq!(seen(&at, Some(&saw)).0, 200); // An earlier time leaves the later one. let (_, kept) = seen("2000-01-01T00:00:00Z", Some(&saw)); assert_eq!( serde_json::from_str::(&kept).unwrap()["seenAt"], at.as_str() ); assert_eq!(seen("yesterday", Some(&saw)).0, 400); let (_, heard) = asked("listNotifications", &third, &server, ""); assert_eq!(heard["notifications"][0]["isRead"], true); assert_eq!(heard["seenAt"], at.as_str()); assert_eq!(asked("getUnreadCount", &third, &server, "").1["count"], 0); // Only as someone, from this site's pages or with a token, and only // where there is an index. let list = xrpc("listNotifications"); assert_eq!(browse("GET", &list, None, None, None).0, 401); assert_eq!(browse("GET", &list, me, None, None).0, 200); let body = Some(json!({ "seenAt": at })); assert_eq!(browse("POST", &xrpc("updateSeen"), me, away, body).0, 403); assert_eq!(asked("listNotifications", &third, &plain, "").0, 404); // Logging out ends the session, and every cookie signed before. let logout = format!("{}/oauth/logout", server.base); assert_eq!(browse("POST", &logout, me, away, None).0, 403); let (status, set, _) = browse("POST", &logout, me, None, None); assert_eq!(status, 200); assert!(set[0].starts_with("knotless_session=;"), "{set:?}"); assert_eq!(viewer(me), json!({"login": true})); assert_eq!(star("starRepo", me, None, &repo).0, 401); assert_eq!(browse("POST", &logout, None, None, None).0, 200); // A server that is no gateway has no pages to log in to. let site = serve_plain(); for (method, path) in [ ("GET", "/oauth/web-client-metadata.json"), ("POST", "/oauth/logout"), ("GET", "/oauth/web-callback?state=x"), ] { let url = format!("{site}{path}"); assert_eq!(browse(method, &url, me, None, None).0, 404, "{path}"); } let start = json!({"handle": fan.account.handle}); assert_eq!( browse( "POST", &format!("{site}/oauth/login"), None, None, Some(start) ) .0, 404 ); let anyone = browse( "GET", &format!("{site}/xrpc/dev.knotless.getViewer"), me, None, None, ) .2; assert_eq!(anyone, json!({"login": false})); } /// From a pull's page, its owner sees whether it can merge and lands it, /// queues it, retargets it and runs its checks, and names the runners on /// the repository's settings, through the same code as the command line. #[test] fn landing_pulls_from_the_pages() { // Started first, so its index sees the repositories made after. let server = gateway_with(None, true); let mut owner = person(account()); owner.set("KNOTLESS_APPVIEW", &server.base); let ci = person(account()); let url = owner.url("landed"); let work = owner.work("w", 1); std::fs::create_dir_all(work.join(".knotless/workflows")).unwrap(); let on_pull = "on: {pull: {}}\nsteps: [{run: 'true'}]\n"; owner.commit_file(&work, ".knotless/workflows/ci.yml", on_pull); owner.git(&work, &["push", "-q", &url, "main"]); owner.git(&work, &["push", "-q", &url, "main:next"]); owner.git(&work, &["checkout", "-q", "-b", "x"]); let head = owner.commit_file(&work, "x", "x\n"); owner.git(&work, &["push", "-q", &url, "x"]); let (code, issue) = owner.cli(&["issue", "create", &url, "Broken"], b""); assert_eq!(code, 0, "{issue}"); let fixes = format!("Fixes {issue}"); let opened = ["pull", "create", &url, "main", &url, "x", "X", "-m", &fixes]; let (code, pull) = owner.cli(&opened, b""); assert_eq!(code, 0, "{pull}"); let repo = format!("at://{}/dev.knotless.repo/landed", owner.account.did); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let mine = session_cookie(&server, &owner.account); let theirs = session_cookie(&server, &ci.account); let (me, them) = (Some(mine.as_str()), Some(theirs.as_str())); let ask = |nsid: &str, query: &str, cookie: Option<&str>| { browse( "GET", &format!("{}?{query}", xrpc(nsid)), cookie, None, None, ) }; let write = |nsid: &str, cookie: Option<&str>, body: Value| { browse("POST", &xrpc(nsid), cookie, None, Some(body)) }; let of_pull = format!("repo={repo}&pull={pull}"); let status = |cookie| ask("getMergeStatus", &of_pull, cookie); let thread = || ask("getThread", &format!("repo={repo}&uri={pull}"), None).2; // Only someone asks whether it can merge. It names no runners, so // nothing waits for its checks: it merges with a merge commit, and takes x // along. assert_eq!(status(None).0, 401); // One there is none of, read again from its author's repo, is none. let nothing = format!("repo={repo}&pull={pull}x"); assert_eq!(ask("getMergeStatus", ¬hing, me).0, 404); let (code, _, said) = status(me); assert_eq!(code, 200, "{said}"); let may = json!({"merge": true, "force": true, "queue": false, "autoMerge": false, "base": true, "head": true, "rerun": false}); assert_eq!( (&said["state"], &said["checks"], &said["method"]), (&json!("clean"), &json!("none"), &json!("merge")), "{said}" ); assert_eq!( (&said["deletes"], &said["head"]), (&json!("x"), &json!(head)) ); assert_eq!(said["may"], may); // Anyone else may do nothing to it. let none = json!({"merge": false, "force": false, "queue": false, "autoMerge": false, "base": false, "head": false, "rerun": false}); let theirs = status(them).2; assert_eq!(theirs["may"], none); // And says why it would not merge for them. let why = theirs["reason"].as_str().unwrap(); assert!( why.starts_with("you may not merge it: only the owner"), "{why}" ); // The workflows at a ref, and what runs each there. let listed = ask("listWorkflows", &format!("repo={repo}"), None).2; let ci_yml = json!({"name": "ci", "push": false, "pull": true, "asked": false}); assert_eq!( listed, json!({"ref": "refs/heads/main", "workflows": [ci_yml]}) ); let nowhere = ask("listWorkflows", &format!("repo={repo}&ref=nope"), None); assert_eq!(nowhere.0, 404, "{:?}", nowhere.2); // Its owner names a runner; no one else can. let runners = |cookie| ask("listRunners", &format!("repo={repo}"), cookie).2; assert_eq!(runners(None), json!({"runners": []})); let runner = json!({"repo": repo, "runner": ci.account.handle}); assert_eq!(write("addRunner", them, runner.clone()).0, 403); assert_eq!(write("addRunner", None, runner.clone()).0, 401); let (code, _, named) = write("addRunner", me, runner.clone()); assert_eq!(code, 200, "{named}"); assert_eq!(named["runners"][0]["did"], ci.account.did); assert_eq!(runners(None)["runners"][0]["handle"], ci.account.handle); // Now its checks are waited for, and none has run. let said = status(me).2; assert_eq!( (&said["state"], &said["checks"]), (&json!("blocked"), &json!("pending")) ); assert!( said["reason"] .as_str() .unwrap() .contains("none has run yet"), "{said}" ); assert_eq!( (&said["may"]["queue"], &said["may"]["rerun"]), (&json!(false), &json!(true)) ); // Its owner asks the runners to run them again, or one workflow at a // ref, with variables. let again = json!({"repo": repo, "pull": pull, "workflow": "ci"}); let (code, _, asked) = write("requestRerun", me, again.clone()); assert_eq!(code, 200, "{asked}"); let rkey = asked["uri"].as_str().unwrap().rsplit('/').next().unwrap(); assert_eq!( owner.record("dev.knotless.rerun", rkey)["pull"], pull.as_str() ); let at_ref = json!({"repo": repo, "ref": "main", "workflow": "ci", "env": [{"name": "A", "value": "b"}]}); let asked = write("requestRerun", me, at_ref).2; let rkey = asked["uri"].as_str().unwrap().rsplit('/').next().unwrap(); let rerun = owner.record("dev.knotless.rerun", rkey); assert_eq!( (&rerun["ref"], &rerun["env"][0]["name"]), (&json!("refs/heads/main"), &json!("A")) ); assert_eq!(write("requestRerun", them, again).0, 400); // Its runner files a check that passed at its head on main, which the // box reads from the index once it hears of it. let key = owner.record("dev.knotless.repo", "landed")["key"].clone(); let base = owner.git(&work, &["rev-parse", "main"]); let key_str = key.as_str().unwrap(); let rkey = knotless::keys::check_rkey( &owner.account.did, key_str, &pull, &head, Some(base.as_str()), "ci", ); let check = json!({ "$type": "dev.knotless.check", "repo": {"uri": repo, "key": key}, "ref": "refs/heads/x", "pull": pull, "commit": head, "baseCommit": base, "workflow": "ci", "status": "success", "startedAt": "2026-10-31T00:00:00.000Z", }); ci.put_record("dev.knotless.check", &rkey, check); wait_until(|| status(me).2["checks"] == "passed"); assert_eq!(status(me).2["state"], "clean"); // Its runner may not push, so lands the queue through this server, // which does not write for its owner yet: queueing it would land // nothing, and is refused, until they delegate. let one = json!({"repo": repo, "pull": pull}); let rule = json!({"repo": repo, "branch": "main", "merges": true, "queue": true}); assert_eq!(write("protectBranch", me, rule).0, 200); assert_eq!(status(me).2["may"]["queue"], true); assert_eq!(status(me).2["queueBlocked"], "undelegated"); let viewer = || ask("getViewer", "", me).2; assert!(viewer().get("delegated").is_none(), "{}", viewer()); let (code, _, refused) = write("queuePull", me, one.clone()); assert_eq!(code, 400, "{refused}"); let why = refused["message"].as_str().unwrap(); assert!(why.contains("once its owner delegates writes"), "{why}"); let (code, out) = owner.cli( &["account", "delegate", &owner.account.handle, &server.did], b"", ); assert_eq!(code, 0, "{out}"); assert_eq!(viewer()["delegated"], true); assert!(status(me).2.get("queueBlocked").is_none()); // Its record naming this gateway changes nothing: it still lands here. let plain = owner.record("dev.knotless.repo", "landed"); let mut named = plain.clone(); named["gateway"] = json!(server.did); owner.put_record("dev.knotless.repo", "landed", named); let settings = || ask("getRepoSettings", &format!("repo={repo}"), me).2; wait_until(|| settings()["gateway"] == server.did.as_str()); assert!(status(me).2.get("queueBlocked").is_none()); owner.put_record("dev.knotless.repo", "landed", plain); wait_until(|| settings().get("gateway").is_none()); // Queued, and taken out, as the page shows at once. let (code, _, place) = write("queuePull", me, one.clone()); assert_eq!(code, 200, "{place}"); assert_eq!(place, json!({"base": "main", "position": 1, "of": 1})); assert_eq!(thread()["queue"]["state"], "queued"); assert_eq!(write("queuePull", them, one.clone()).0, 400); // Its pages lead to the queue while it waits there, rule or no rule, // and it can still leave, once it is gone they do not. let queues = || ask("getRepo", &format!("repo={repo}"), None).2["mergeQueue"].clone(); assert_eq!(queues(), true); let main = json!({"repo": repo, "branch": "main"}); assert_eq!(write("unprotectBranch", me, main).0, 200); assert_eq!(queues(), true); assert_eq!( write("leaveQueue", me, one.clone()), (200, Vec::new(), json!({})) ); assert!(thread().get("queue").is_none(), "{}", thread()); assert_eq!(queues(), Value::Null); assert_eq!(write("leaveQueue", me, one.clone()).0, 400); // Into a branch that takes pulls only through the queue, the page // queues it: a merge from there is refused, saying why. let rule = json!({"repo": repo, "branch": "main", "queue": true}); let (code, _, rules) = write("protectBranch", me, rule); assert_eq!(code, 200, "{rules}"); let held = json!([{"branch": "main", "history": "linear", "queue": true}]); assert_eq!(rules["rules"], held); assert_eq!(status(me).2["queueOnly"], true); let (code, _, refused) = write("mergePull", me, one.clone()); let why = refused["message"].as_str().unwrap_or_default(); assert!( code == 400 && why.contains("only through the merge queue"), "{refused}" ); let main = json!({"repo": repo, "branch": "main"}); let (code, _, rules) = write("unprotectBranch", me, main); assert_eq!((code, rules), (200, json!({"rules": []}))); assert!(status(me).2.get("queueOnly").is_none()); // Pointed at another branch, and back. let next = json!({"repo": repo, "pull": pull, "base": "next"}); assert_eq!(write("retargetPull", me, next).2, json!({})); assert_eq!(thread()["base"], "next"); let back = json!({"repo": repo, "pull": pull, "base": "main"}); assert_eq!(write("retargetPull", me, back).2, json!({})); let nowhere = json!({"repo": repo, "pull": pull, "base": "nope"}); assert_eq!(write("retargetPull", me, nowhere).0, 400); // Set to merge when its checks pass, by its owner alone: the page says // by whom, until it moves to another branch, and again once back. assert_eq!(status(me).2["may"]["autoMerge"], true); assert!(status(me).2.get("autoMerge").is_none()); assert_eq!(write("setAutoMerge", them, one.clone()).0, 400); let (code, _, set) = write("setAutoMerge", me, one.clone()); assert_eq!(code, 200, "{set}"); assert_eq!(set["commit"], status(me).2["head"], "{set}"); let auto = || status(me).2["autoMerge"].clone(); assert_eq!(auto()["state"], "set"); assert_eq!(auto()["by"]["handle"], owner.account.handle.as_str()); let next = json!({"repo": repo, "pull": pull, "base": "next"}); assert_eq!(write("retargetPull", me, next).2, json!({})); assert_eq!(auto()["state"], "retargeted"); let back = json!({"repo": repo, "pull": pull, "base": "main"}); assert_eq!(write("retargetPull", me, back).2, json!({})); assert_eq!(auto()["state"], "set"); // Cancelled, by its owner: it was not someone else's to cancel. let undo = json!({"repo": repo, "pull": pull, "undo": true}); assert_eq!(write("setAutoMerge", them, undo.clone()).0, 400); assert_eq!( write("setAutoMerge", me, undo.clone()), (200, Vec::new(), json!({})) ); assert_eq!(auto(), Value::Null); assert_eq!(write("setAutoMerge", me, undo).0, 400); // With no runner named, it merges at once, as the page shows. let (code, _, named) = write("removeRunner", me, runner); assert_eq!((code, named), (200, json!({"runners": []}))); assert_eq!(write("mergePull", None, one.clone()).0, 401); assert_eq!(write("mergePull", them, one.clone()).0, 400); let (code, _, merged) = write("mergePull", me, one.clone()); assert_eq!(code, 200, "{merged}"); // A merge commit on main, though it could have fast-forwarded. let commit = merged["commit"].clone(); assert_ne!(commit, json!(head)); assert_eq!( merged, json!({"commit": commit, "deleted": "x", "closed": [{"uri": issue}]}) ); assert_eq!(thread()["item"]["state"], "merged"); let branches = ask("listBranches", &format!("repo={repo}&commits=true"), None).2; // main shows the merge at once, not once the feed tells of it. let mut all = branches["branches"].as_array().unwrap().iter(); let main = all.find(|b| b["name"] == "main").unwrap(); let made = commit.as_str().unwrap(); assert!(main["commit"].to_string().contains(made), "{branches}"); // Merged, its owner may revert it, which opens a pull the page lands // on at once; anyone else may not. let as_me = ask("getThread", &format!("repo={repo}&uri={pull}"), me).2; assert_eq!(as_me["viewer"]["revert"], true, "{as_me}"); assert_eq!(write("revertPull", None, one.clone()).0, 401); assert_eq!(write("revertPull", them, one.clone()).0, 400); let undo = json!({"repo": repo, "pull": pull, "reason": "too soon"}); let (code, _, reverted) = write("revertPull", me, undo); assert_eq!(code, 200, "{reverted}"); let rkey = pull.rsplit('/').next().unwrap(); assert_eq!(reverted["branch"], format!("revert/{rkey}")); let uri = reverted["uri"].as_str().unwrap(); let opened = ask("getThread", &format!("repo={repo}&uri={uri}"), None).2; assert_eq!(opened["item"]["title"], "Revert \"X\"", "{opened}"); let (code, _, again) = write("mergePull", me, one); assert!( code == 400 && again["message"].as_str().unwrap().contains("is merged"), "{again}" ); // Nor can it say whether a merged pull merges. let (code, _, said) = status(me); assert_eq!(code, 400, "{said}"); assert!( said["message"].as_str().unwrap().ends_with("is merged"), "{said}" ); // The pull it undoes names it once the revert merges. assert!(as_me.get("revertedBy").is_none(), "{as_me}"); let landing = json!({"repo": repo, "pull": uri}); assert_eq!(write("mergePull", me, landing).0, 200); let undone = || ask("getThread", &format!("repo={repo}&uri={pull}"), me).2; wait_until(|| undone().get("revertedBy").is_some()); let undone = undone(); assert_eq!( undone["revertedBy"], json!({"uri": uri, "title": "Revert \"X\""}) ); } /// The account logged in to the pages changes its settings there: its /// profile and picture, the addresses its commits show as its by, whom it /// blocks, through the same code as the command line, and its preferences: /// which notifications it hears of, and the theme its pages take at login. #[test] fn settings_from_the_pages() { const APPEARANCE_PREF: &str = "dev.knotless.getPreferences#appearancePref"; const NOTIFICATION_PREF: &str = "dev.knotless.getPreferences#notificationPref"; // Started first, so its index sees the repositories made after. let server = gateway_with(None, true); let me_at = person(account()); let work = me_at.work("w", 1); me_at.git(&work, &["push", "-q", &me_at.url("mine"), "main"]); let other = person(account()); let elsewhere = other.work("w", 1); other.git(&elsewhere, &["push", "-q", &other.url("theirs"), "main"]); let set = log_in(&server, &me_at.account, ""); let session = set.iter().find(|c| c.starts_with("knotless_session=")); let cookie = pair(session.unwrap()).to_string(); let me = Some(cookie.as_str()); let away = Some("https://evil.example"); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let get = |nsid: &str, cookie: Option<&str>| browse("GET", &xrpc(nsid), cookie, None, None); let send = |nsid: &str, cookie: Option<&str>, body: Value| { browse("POST", &xrpc(nsid), cookie, None, Some(body)) }; let profile = |of: &str, cookie: Option<&str>| { let url = format!("{}?actor={of}", xrpc("getProfile")); browse("GET", &url, cookie, None, None).2 }; let mine = me_at.account.did.clone(); // How it shows: its knotless profile alone, then as changed. assert_eq!(get("getOwnProfile", None).0, 401); assert_eq!(get("getOwnProfile", me).2, json!({})); let fields = json!({"displayName": "Me", "description": "Writes code.", "pronouns": "they/them", "links": [{"url": "https://me.example", "title": "Home"}]}); let (status, _, now) = send("updateProfile", me, fields); assert_eq!(status, 200, "{now}"); assert_eq!(now["displayName"], "Me"); assert_eq!(now["links"][0]["title"], "Home"); assert_eq!(get("getOwnProfile", me).2, now); let record = me_at.record("dev.knotless.profile", "self"); assert_eq!(record["pronouns"], "they/them"); wait_until(|| profile(&mine, None)["displayName"] == "Me"); // Said to be a bot, and then not. let (_, _, now) = send("updateProfile", me, json!({"bot": true})); assert_eq!( (&now["bot"], &now["displayName"]), (&json!(true), &json!("Me")) ); assert_eq!(me_at.record("dev.knotless.profile", "self")["bot"], true); wait_until(|| profile(&mine, None)["bot"] == true); let (_, _, now) = send("updateProfile", me, json!({"bot": false})); assert_eq!(now.get("bot"), None); assert_eq!( me_at.record("dev.knotless.profile", "self").get("bot"), None ); // Refused for what it asks, in the words of the code refusing it. let long = json!({"pronouns": "x".repeat(21)}); let (status, _, why) = send("updateProfile", me, long); assert_eq!(status, 400); assert!( why["message"].as_str().unwrap().contains("longer than 20"), "{why}" ); let script = json!({"links": [{"url": "javascript:alert(1)"}]}); assert_eq!(send("updateProfile", me, script).0, 400); assert_eq!(send("updateProfile", None, json!({})).0, 401); let from_away = browse("POST", &xrpc("updateProfile"), me, away, Some(json!({}))); assert_eq!(from_away.0, 403); // A picture, as the whole of the body. let picture = |body: &[u8], cookie: Option<&str>| { let req = reqwest::blocking::Client::new().post(xrpc("uploadAvatar")); let mut req = req.header("content-type", "image/png").body(body.to_vec()); if let Some(cookie) = cookie { req = req.header("cookie", cookie); } let res = req.send().unwrap(); (res.status().as_u16(), res.json::().unwrap()) }; let (status, now) = picture(b"\x89PNG\r\n\x1a\nrest", me); assert_eq!(status, 200, "{now}"); let avatar = now["avatar"].as_str().unwrap().to_string(); assert!(avatar.contains("dev.knotless.getAvatar"), "{avatar}"); assert_eq!(now["displayName"], "Me"); wait_until(|| profile(&mine, None)["avatar"] == avatar.as_str()); assert_eq!(picture(b"", me).0, 400); assert_eq!(picture(b"GIF89a", me).0, 400); assert_eq!(picture(b"\x89PNG\r\n\x1a\n", None).0, 401); // All of it taken out again, the record goes. let cleared = json!({"displayName": "", "description": "", "pronouns": "", "links": [], "removeAvatar": true}); assert_eq!(send("updateProfile", me, cleared).2, json!({})); assert_eq!(me_at.record("dev.knotless.profile", "self"), Value::Null); // The addresses its commits show as its by: its first push claimed one. assert_eq!(get("getEmails", None).0, 401); let before = get("getEmails", me).2["count"].as_u64().unwrap(); let email = |nsid: &str, address: &str| send(nsid, me, json!({ "email": address })); let added = email("addEmail", "Me@Example.com").2; assert_eq!(added, json!({"changed": true, "count": before + 1})); assert_eq!(email("addEmail", " me@example.com ").2["changed"], false); assert_eq!(get("getEmails", me).2["count"], before + 1); let removed = email("removeEmail", "me@example.com").2; assert_eq!(removed, json!({"changed": true, "count": before})); assert_eq!(email("removeEmail", "me@example.com").2["changed"], false); assert_eq!(email("addEmail", "nowhere").0, 400); let anyone = send("addEmail", None, json!({"email": "a@example.com"})); assert_eq!(anyone.0, 401); // Whom it blocks, from all its repositories or from one. assert_eq!(get("listBlocks", None).0, 401); assert_eq!(get("listBlocks", me).2, json!({"blocks": []})); let them = other.account.did.as_str(); let (status, _, blocked) = send("blockActor", me, json!({"subject": other.account.handle})); assert_eq!(status, 200, "{blocked}"); assert_eq!(blocked, json!({"subject": them, "blocking": true})); let repo = format!("at://{mine}/dev.knotless.repo/mine"); let one = json!({"subject": them, "repo": repo}); assert_eq!(send("blockActor", me, one.clone()).2["blocking"], true); let listed = get("listBlocks", me).2; let blocks = listed["blocks"].as_array().unwrap(); assert_eq!(blocks.len(), 2, "{listed}"); assert!(blocks.iter().all(|b| b["subject"]["did"] == them)); let from_one = blocks.iter().filter(|b| b["repo"]["name"] == "mine"); assert_eq!(from_one.count(), 1); // The profile's menu knows, from the index, at once. let blocking = profile(them, me)["viewer"]["blocking"].clone(); let prefix = format!("at://{mine}/dev.knotless.block/"); assert!( blocking.as_str().unwrap().starts_with(&prefix), "{blocking}" ); assert_eq!(profile(them, None).get("viewer"), None); // Not itself, no one, nor another's repository, nor what is none. assert_eq!(send("blockActor", me, json!({"subject": mine})).0, 400); let nobody = send("blockActor", me, json!({"subject": "nobody.invalid"})); assert_ne!(nobody.0, 200); let foreign = format!("at://{them}/dev.knotless.repo/theirs"); let theirs = json!({"subject": them, "repo": foreign}); assert_eq!(send("blockActor", me, theirs).0, 400); let nowhere = json!({"subject": them, "repo": "at://nope"}); assert_eq!(send("blockActor", me, nowhere).0, 400); assert_eq!(send("blockActor", None, json!({"subject": them})).0, 401); // Unblocked from the one, then from all. assert_eq!(send("unblockActor", me, one).2["blocking"], false); let unblocked = send("unblockActor", me, json!({"subject": them})).2; assert_eq!(unblocked, json!({"subject": them, "blocking": false})); assert_eq!(get("listBlocks", me).2, json!({"blocks": []})); wait_until(|| profile(them, me)["viewer"].get("blocking").is_none()); // Its preferences: its notifications, every reason from anyone, until // it says, and no appearance. assert_eq!(get("getPreferences", None).0, 401); let notified = |answer: &Value| { let all = answer["preferences"].as_array().unwrap().clone(); let n = all.iter().find(|p| p["$type"] == NOTIFICATION_PREF); n.unwrap().clone() }; let kept = get("getPreferences", me).2; assert_eq!(kept["preferences"].as_array().unwrap().len(), 1, "{kept}"); assert_eq!(notified(&kept)["followedOnly"], false); assert_eq!( notified(&kept)["reasons"][0], json!({"reason": "star", "enabled": true}) ); let put = |cookie: Option<&str>, origin: Option<&str>, prefs: Value| { let body = json!({ "preferences": prefs }); browse("POST", &xrpc("putPreferences"), cookie, origin, Some(body)) }; let quiet = json!([{"$type": NOTIFICATION_PREF, "reasons": [{"reason": "follow", "enabled": false}], "followedOnly": true}]); let (status, _, now) = put(me, None, quiet.clone()); assert_eq!(status, 200, "{now}"); assert_eq!( notified(&now)["reasons"][1], json!({"reason": "follow", "enabled": false}) ); assert_eq!(notified(&now)["followedOnly"], true); assert_eq!(get("getPreferences", me).2, now); // A theme beside them, which leaves them be; then the notifications // whole again, a reason not named on and followedOnly off. let dracula = json!([{"$type": APPEARANCE_PREF, "theme": "dracula"}]); let themed = put(me, None, dracula).2; let all = themed["preferences"].as_array().unwrap(); assert_eq!( all[0], json!({"$type": APPEARANCE_PREF, "theme": "dracula"}) ); assert_eq!(notified(&themed), notified(&now)); let anew = json!([{"$type": NOTIFICATION_PREF}]); let again = notified(&put(me, None, anew).2); assert_eq!(again["reasons"][1]["enabled"], true); assert_eq!(again["followedOnly"], false); // No kind, theme or reason there is none of, nor reasons that are not // a list of them; only from the site, as someone. let refused = |prefs: Value, why: &str| { let (status, _, said) = put(me, None, prefs); assert_eq!(status, 400, "{said}"); assert!(said.to_string().contains(why), "{said}"); }; refused( json!([{"$type": "app.bsky.actor.defs#adultContentPref"}]), "is no kind", ); refused( json!([{"$type": APPEARANCE_PREF, "theme": "x; Path=/evil"}]), "is no theme", ); refused( json!([{"$type": NOTIFICATION_PREF, "reasons": "all"}]), "reasons", ); let poke = json!([{"$type": NOTIFICATION_PREF, "reasons": [{"reason": "poke", "enabled": false}]}]); refused(poke, "is no reason"); assert_eq!(put(me, away, quiet.clone()).0, 403); assert_eq!(put(None, None, quiet.clone()).0, 401); // Logging in again, the browser takes the theme it chose. let set = log_in(&server, &me_at.account, ""); let theme = set.iter().find(|c| c.starts_with("theme=")).unwrap(); assert!(theme.starts_with("theme=dracula;"), "{theme}"); assert!(!theme.contains("HttpOnly"), "{theme}"); // A theme of its own: its palette, held to its lexicon, which logging // in copies to the browser beside the theme it is drawn over. let palette = json!({"colorScheme": "dark", "background": "#101820", "text": "#F2F2F2", "primary": "#6ea8fe", "accent": "#8ab4f8", "syntax": {"strings": "#a5d6a7"}}); let source = format!("at://{mine}/dev.knotless.theme/3lzyx"); let own = json!([{"$type": APPEARANCE_PREF, "theme": "custom", "palette": palette.clone(), "source": source}]); let (status, _, said) = put(me, None, own); assert_eq!(status, 200, "{said}"); assert_eq!(said["preferences"][0]["palette"]["text"], "#f2f2f2"); assert_eq!(said["preferences"][0]["source"], source.as_str()); let set = log_in(&server, &me_at.account, ""); let cookie_of = |name: &str| { let found = set.iter().find(|c| c.starts_with(&format!("{name}="))); found.unwrap().clone() }; assert!(cookie_of("theme").starts_with("theme=custom-dark;")); let drawn = "palette=dark.background-101820.text-f2f2f2.primary-6ea8fe.accent-8ab4f8.strings-a5d6a7;"; assert!( cookie_of("palette").starts_with(drawn), "{}", cookie_of("palette") ); refused( json!([{"$type": APPEARANCE_PREF, "theme": "custom"}]), "none is given", ); refused( json!([{"$type": APPEARANCE_PREF, "theme": "custom", "palette": {"colorScheme": "dark"}}]), "a palette gives it", ); refused( json!([{"$type": APPEARANCE_PREF, "palette": palette, "source": "https://evil.example"}]), "is no dev.knotless.theme", ); // Two themes, one by day and one at night, which logging in copies to // the browser beside each other, the night's in cookies of its own. let pair = json!([{"$type": APPEARANCE_PREF, "theme": "latte", "night": {"theme": "custom", "palette": palette.clone(), "source": source}}]); let (status, _, said) = put(me, None, pair); assert_eq!(status, 200, "{said}"); assert_eq!( said["preferences"][0]["night"]["palette"]["text"], "#f2f2f2" ); let set = log_in(&server, &me_at.account, ""); let cookie_of = |name: &str| { let found = set.iter().find(|c| c.starts_with(&format!("{name}="))); found.unwrap().clone() }; assert!(cookie_of("theme").starts_with("theme=latte;")); assert!(cookie_of("palette").contains("Max-Age=0")); assert!(cookie_of("theme-night").starts_with("theme-night=custom-dark;")); let night = cookie_of("palette-night"); let drawn_at_night = drawn.replacen("palette=", "palette-night=", 1); assert!(night.starts_with(&drawn_at_night), "{night}"); refused( json!([{"$type": APPEARANCE_PREF, "theme": "light", "night": {"theme": "custom"}}]), "night: theme custom", ); // The themes it added, kept apart from the one it is drawn in: each a // copy, held to its lexicon, set whole and left be by a theme chosen. const THEMES_PREF: &str = "dev.knotless.getPreferences#themesPref"; let theirs = format!("at://{}/dev.knotless.theme/3lzyy", other.account.did); let one = json!({"uri": theirs, "name": "Sea glass", "creator": other.account.did, "palette": palette.clone(), "addedAt": "2026-10-30T12:00:00.000Z"}); let added = json!([{"$type": THEMES_PREF, "themes": [one.clone(), {"uri": source, "palette": palette.clone()}]}]); let (status, _, said) = put(me, None, added); assert_eq!(status, 200, "{said}"); let kept_of = |answer: &Value| { let all = answer["preferences"].as_array().unwrap().clone(); all.into_iter().find(|p| p["$type"] == THEMES_PREF) }; let themes = kept_of(&said).unwrap(); assert_eq!(themes["themes"][0]["name"], "Sea glass"); assert_eq!(themes["themes"][0]["addedAt"], "2026-10-30T12:00:00.000Z"); assert_eq!(themes["themes"][0]["palette"]["text"], "#f2f2f2"); assert_eq!(themes["themes"][1].get("name"), None); let chosen = put( me, None, json!([{"$type": APPEARANCE_PREF, "theme": "mocha"}]), ) .2; assert_eq!(kept_of(&chosen), Some(themes)); let emptied = put(me, None, json!([{"$type": THEMES_PREF, "themes": []}])).2; assert_eq!(kept_of(&emptied).unwrap()["themes"], json!([])); let many = vec![one.clone(); 51]; refused( json!([{"$type": THEMES_PREF, "themes": many}]), "50 at most", ); refused(json!([{"$type": THEMES_PREF}]), "a list is wanted"); let with = |key: &str, value: Value| { let mut t = one.clone(); t[key] = value; json!([{"$type": THEMES_PREF, "themes": [t]}]) }; refused( with("uri", json!("https://evil.example")), "is no dev.knotless.theme", ); refused( with("palette", json!({"colorScheme": "dark"})), "a palette gives it", ); refused(with("name", json!("x".repeat(65))), "is no name"); refused(with("creator", json!("alice.test")), "is no DID"); refused(with("addedAt", json!("yesterday")), "is no time"); // An appearance with no theme gives the choice back to the system. let plain = put(me, None, json!([{"$type": APPEARANCE_PREF}])).2; assert_eq!(plain["preferences"][0], json!({"$type": APPEARANCE_PREF})); } /// Themes people make for the pages: published as the account logged in /// to them, listed for anyone, the latest first, and taken back. #[test] fn themes_from_the_pages() { let server = gateway_with(None, true); let maker = person(account()); let set = log_in(&server, &maker.account, ""); let session = set.iter().find(|c| c.starts_with("knotless_session=")); let cookie = pair(session.unwrap()).to_string(); let me = Some(cookie.as_str()); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let send = |nsid: &str, cookie: Option<&str>, body: Value| { browse("POST", &xrpc(nsid), cookie, None, Some(body)) }; let list = |query: &str| { let url = format!("{}{query}", xrpc("listThemes")); browse("GET", &url, None, None, None) }; let paper = json!({"colorScheme": "light", "background": "#ffffff", "text": "#1f2328", "primary": "#1f883d", "accent": "#0969da"}); let ink = json!({"colorScheme": "dark", "background": "#0d1117", "text": "#e6edf3", "primary": "#2f81f7", "accent": "#58a6ff", "panel": "not a colour"}); // None yet; then one, as its maker's record, named as written. assert_eq!(list("").2, json!({"themes": []})); let made = send( "createTheme", me, json!({"name": " Paper ", "palette": paper}), ); let (status, _, made) = made; assert_eq!(status, 200, "{made}"); assert_eq!(made["name"], "Paper"); assert_eq!(made["palette"], paper); assert_eq!(made["creator"]["did"], maker.account.did); let uri = made["uri"].as_str().unwrap().to_string(); let theirs = format!("at://{}/dev.knotless.theme/", maker.account.did); assert!(uri.starts_with(&theirs), "{uri}"); assert_eq!(maker.records("dev.knotless.theme").len(), 1); // Not one with no name, a colour not as #rrggbb, or what a palette // must give; and only as someone logged in. let refused = |body: Value, why: &str| { let (status, _, said) = send("createTheme", me, body); assert_eq!(status, 400, "{said}"); assert!(said.to_string().contains(why), "{said}"); }; refused(json!({"name": " ", "palette": paper}), "a theme's name"); refused(json!({"name": "Ink", "palette": ink}), "panel"); let bare = json!({"colorScheme": "dark"}); refused(json!({"name": "Ink", "palette": bare}), "background"); // Nor one whose text would be hard to read, as the pages check it. let mut pale = paper.clone(); pale["textMuted"] = json!("#eeeeee"); refused( json!({"name": "Pale", "palette": pale}), "textMuted on background", ); let anyone = send( "createTheme", None, json!({"name": "Ink", "palette": paper}), ); assert_eq!(anyone.0, 401); let mut ink = ink; ink["panel"] = json!("#161b22"); let second = send("createTheme", me, json!({"name": "Ink", "palette": ink})); assert_eq!(second.0, 200, "{}", second.2); // Listed the latest first, a page at a time, everyone's or its maker's. let first = list("?limit=1").2; assert_eq!(first["themes"][0]["name"], "Ink"); let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20"); let next = list(&format!("?limit=1&cursor={cursor}")).2; assert_eq!(next["themes"][0]["uri"], uri.as_str()); assert_eq!(next.get("cursor"), None); let by = list(&format!("?actor={}", maker.account.handle)).2; assert_eq!(by["themes"].as_array().unwrap().len(), 2); assert_eq!(list("?actor=nobody.invalid").0, 400); // Found by how its name starts, in any case, in the order of names. let named = |q: &str| { let found = list(&format!("?q={q}")).2; let names = found["themes"].as_array().unwrap().iter(); names.map(|t| t["name"].clone()).collect::>() }; assert_eq!(named("pA"), [json!("Paper")]); assert_eq!(named("%20"), [json!("Ink"), json!("Paper")]); assert_eq!(named("ox"), Vec::::new()); assert_eq!(list("?q=p").2.get("cursor"), None); // By URI, as an account's added themes name them: in the order named, // and only those there are. let ink_uri = second.2["uri"].as_str().unwrap(); let gone = format!("{theirs}3lzyx"); let at = list(&format!("?uris={uri}&uris={gone}&uris={ink_uri}")).2; let uris = at["themes"].as_array().unwrap().iter(); let uris = uris.map(|t| t["uri"].clone()).collect::>(); assert_eq!(uris, [json!(uri), json!(ink_uri)]); assert_eq!(at["themes"][0]["creator"]["handle"], maker.account.handle); let many = vec![format!("uris={gone}"); 51].join("&"); let (status, _, said) = list(&format!("?{many}")); assert_eq!(status, 400, "{said}"); let plain = gateway(); let unindexed = format!("{}/xrpc/dev.knotless.listThemes", plain.base); assert_eq!(browse("GET", &unindexed, None, None, None).0, 404); // Taken back, only its maker's own. let other = "at://did:plc:someoneelse0000000000000/dev.knotless.theme/3lzyx"; let (status, _, said) = send("deleteTheme", me, json!({ "uri": other })); assert_eq!(status, 400, "{said}"); let (status, _, said) = send("deleteTheme", me, json!({ "uri": uri })); assert_eq!(status, 200, "{said}"); assert_eq!(maker.records("dev.knotless.theme").len(), 1); assert_eq!(list("").2["themes"].as_array().unwrap().len(), 1); } /// An operator moderates the appview from its pages, logged in to them, as /// `knotless admin` does with a service token: reads its reports and how /// it is used, and takes an account down and puts it back. No one else /// can, and not from another site's page. #[test] fn operators_moderate_from_the_pages() { let operator = account(); let server = gateway_run(None, true, Duration::ZERO, &operator.did); let op = person(operator); let other = person(account()); let xrpc = |nsid: &str| format!("{}/xrpc/{nsid}", server.base); let get = |nsid: &str, query: &str, cookie: &str| { let url = format!("{}{query}", xrpc(nsid)); browse("GET", &url, Some(cookie), None, None) }; let session = |who: &Account| { let set = log_in(&server, who, ""); let found = set.iter().find(|c| c.starts_with("knotless_session=")); pair(found.unwrap()).to_string() }; let (mine, theirs) = (session(&op.account), session(&other.account)); let viewer = |cookie: &str| get("dev.knotless.getViewer", "", cookie).2; assert_eq!(viewer(&mine)["admin"], true); assert_eq!(viewer(&theirs).get("admin"), None); let (status, _, reports) = get("dev.knotless.listReports", "", &mine); assert_eq!( (status, &reports["reports"]), (200, &json!([])), "{reports}" ); let (status, _, usage) = get("dev.knotless.getUsage", "?days=2", &mine); assert_eq!(status, 200, "{usage}"); assert_eq!(usage["days"].as_array().unwrap().len(), 2); assert_eq!(get("dev.knotless.listReports", "", &theirs).0, 403); let list = xrpc("dev.knotless.listReports"); assert_eq!(browse("GET", &list, None, None, None).0, 401); let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": other.account.did}); let update = xrpc("com.atproto.admin.updateSubjectStatus"); let set = |applied: bool, origin: Option<&str>| { let body = json!({"subject": subject, "takedown": {"applied": applied}}); browse("POST", &update, Some(&mine), origin, Some(body)).0 }; let status = || { let query = format!("?did={}", other.account.did); let said = get("com.atproto.admin.getSubjectStatus", &query, &mine).2; said["takedown"]["applied"].clone() }; assert_eq!(set(true, Some(&server.base)), 200); assert_eq!(status(), true); assert_eq!(set(false, Some("https://evil.example")), 401); assert_eq!(status(), true); assert_eq!(set(false, None), 200); assert_eq!(status(), false); } /// People react to issues and comments on the pages, as the command line /// does: one reaction per account, subject and content, counted by the /// index for everyone, each asker seeing which are theirs; and none by an /// account the owner blocked. #[test] fn reacting_on_the_pages() { // Started first, so its index sees the records made after. let server = gateway_with(None, true); let owner = person(account()); let url = owner.url("reacted"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let fan = person(account()); let (code, issue) = owner.cli(&["issue", "create", &url, "Shiny"], b""); assert_eq!(code, 0, "{issue}"); let (code, said) = owner.cli(&["comment", "add", &url, &issue, "look"], b""); assert_eq!(code, 0, "{said}"); let repo = format!("at://{}/dev.knotless.repo/reacted", owner.account.did); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let thread = |cookie: Option<&str>| { let url = format!("{}?repo={repo}&uri={issue}", xrpc("getThread")); browse("GET", &url, cookie, None, None).2 }; let reactions = |cookie: Option<&str>| { let url = format!("{}?repo={repo}&uri={issue}", xrpc("getReactions")); browse("GET", &url, cookie, None, None) }; wait_until(|| thread(None)["comments"].as_array().map_or(0, Vec::len) == 1); assert_eq!(thread(None)["comments"][0]["uri"], said.as_str()); assert_eq!(thread(None).get("reactions"), None); assert_eq!(reactions(None).2, json!({"subjects": []})); let set = log_in(&server, &fan.account, ""); let session = set.iter().find(|c| c.starts_with("knotless_session=")); let cookie = pair(session.unwrap()).to_string(); let me = Some(cookie.as_str()); let react = |nsid: &str, cookie: Option<&str>, subject: &str, content: &str| { let body = json!({"repo": repo, "subject": subject, "content": content}); browse("POST", &xrpc(nsid), cookie, None, Some(body)) }; let (status, _, state) = react("addReaction", me, &issue, "heart"); assert_eq!(status, 200, "{state}"); assert_eq!(state["subject"], issue.as_str()); assert_eq!(state["reactions"][0]["content"], "heart"); assert_eq!(state["reactions"][0]["count"], 1); let heart = state["reactions"][0]["reaction"] .as_str() .unwrap() .to_string(); assert!(heart.starts_with(&format!("at://{}/dev.knotless.reaction/", fan.account.did))); // Again, the reaction there is. let again = react("addReaction", me, &issue, "heart").2; assert_eq!(again["reactions"][0]["reaction"], heart.as_str()); let records = fan.records("dev.knotless.reaction"); assert_eq!(records.len(), 1); assert_eq!(records[0].1["subject"]["uri"], issue.as_str()); assert!(records[0].1["subject"]["cid"].is_string()); assert_eq!(records[0].1["content"], "heart"); // On a comment, in GitHub's order however they were made. assert_eq!(react("addReaction", me, &said, "eyes").0, 200); let both = react("addReaction", me, &said, "+1").2; assert_eq!(both["reactions"][0]["content"], "+1"); assert_eq!(both["reactions"][1]["content"], "eyes"); // Everyone sees the counts; only the fan which are theirs. let mine = thread(me); assert_eq!(mine["reactions"][0]["reaction"], heart.as_str()); assert_eq!(mine["comments"][0]["reactions"][1]["content"], "eyes"); let anyone = thread(None); assert_eq!( anyone["reactions"], json!([{"content": "heart", "count": 1}]) ); assert_eq!(anyone["comments"][0]["reactions"][0]["count"], 1); let (status, _, counted) = reactions(None); assert_eq!(status, 200, "{counted}"); assert_eq!(counted["subjects"][0]["subject"], issue.as_str()); assert_eq!(counted["subjects"][1]["subject"], said.as_str()); assert!(reactions(me).2["subjects"][1]["reactions"][0]["reaction"].is_string()); // The command line shows them, from the appview. let mut reader = Sandbox::new(); reader.set("KNOTLESS_APPVIEW", &server.base); let (code, shown) = reader.cli(&["issue", "show", &url, &issue], b""); assert_eq!(code, 0, "{shown}"); assert!(shown.contains("reactions: heart 1"), "{shown}"); assert!(shown.contains("reactions: +1 1 eyes 1"), "{shown}"); // Taking one back takes every record of it. let (status, _, left) = react("removeReaction", me, &said, "eyes"); assert_eq!(status, 200, "{left}"); assert_eq!(left["reactions"].as_array().unwrap().len(), 1); assert_eq!(left["reactions"][0]["content"], "+1"); assert_eq!(react("removeReaction", me, &said, "eyes").0, 200); assert_eq!(fan.records("dev.knotless.reaction").len(), 2); // Only as someone logged in, with one of the eight, to what the // repository's pages show. assert_eq!(react("addReaction", None, &issue, "heart").0, 401); assert_eq!(react("addReaction", me, &issue, "tada").0, 400); let elsewhere = format!("at://{}/dev.knotless.issue/3zzzzzzzzzzzz", fan.account.did); assert_eq!(react("addReaction", me, &elsewhere, "heart").0, 404); let nowhere = format!("at://{}/dev.knotless.repo/nowhere", owner.account.did); let body = json!({"repo": nowhere, "subject": issue, "content": "heart"}); assert_eq!( browse("POST", &xrpc("addReaction"), me, None, Some(body)).0, 404 ); let bad = format!("{}?repo={repo}&uri=nope", xrpc("getReactions")); assert_eq!(browse("GET", &bad, None, None, None).0, 400); let plain = gateway(); let unindexed = format!( "{}/xrpc/dev.knotless.getReactions?repo={repo}&uri={issue}", plain.base ); assert_eq!(browse("GET", &unindexed, None, None, None).0, 404); if net().spaces { let hidden = owner.url("hidden"); assert_eq!( owner.cli(&["repo", "create", &hidden, "--private"], b"").0, 0 ); let private = format!("at://{}/dev.knotless.repo/hidden", owner.account.did); let body = json!({"repo": private, "subject": issue, "content": "heart"}); assert_eq!( browse("POST", &xrpc("addReaction"), me, None, Some(body)).0, 404 ); } // Blocked from the repository, the fan's reactions count no more. let fan_handle = fan.account.handle.clone(); assert_eq!(owner.cli(&["mod", "block", &fan_handle, &url], b"").0, 0); wait_until(|| reactions(None).2 == json!({"subjects": []})); assert_eq!( owner .cli(&["mod", "block", &fan_handle, &url, "--undo"], b"") .0, 0 ); wait_until(|| reactions(None).2["subjects"].as_array().map_or(0, Vec::len) == 2); } /// A server that is no gateway, nor an appview, until the process ends. fn serve_plain() -> String { let s = Sandbox::new(); let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let app = App::build(ctx(&s), Duration::ZERO, None, None); let router = router(app); std::thread::spawn(move || { let _keep = s; rt.block_on(async move { axum::serve(listener, router).await.unwrap() }); }); base } /// A GET of `url` as a browser holding `cookie`, if any: its status, its /// `cache-control` and `vary`, and its body. fn looked(url: &str, cookie: Option<&str>) -> (u16, String, String, String) { let mut req = reqwest::blocking::Client::new().get(url); if let Some(cookie) = cookie { req = req.header("cookie", cookie); } let res = req.send().unwrap(); let header = |name: &str| { let all = res.headers().get_all(name).iter(); let all: Vec<&str> = all.map(|v| v.to_str().unwrap()).collect(); all.join(", ") }; let (cache, vary) = (header("cache-control"), header("vary")); (res.status().as_u16(), cache, vary, res.text().unwrap()) } /// The session cookie `who` logs in to `server`'s pages with. fn session_cookie(server: &Server, who: &Account) -> String { let set = log_in(server, who, ""); let session = set.iter().find(|c| c.starts_with("knotless_session=")); pair(session.unwrap()).to_string() } /// A private repository shows on the pages to its members, each logged in /// to them and read as themselves, as a public one does; to anyone else as /// a repository that is not there; and to no one in a card, or from any /// cache. Removed from it, or logged out, a member reads it no more. #[test] fn private_repositories_on_the_pages() { // Started first, so its index sees the repositories made after; its // mirrors kept a while, as a viewer's is between their looks. let server = gateway_kept(None, true, Duration::from_secs(60)); let [owner, member, outsider, forker] = people(); let outsider_in = session_cookie(&server, &outsider.account); let xrpc = |nsid: &str, query: &str| format!("{}/xrpc/dev.knotless.{nsid}?{query}", server.base); let repo = |name: &str| format!("repo=at://{}/dev.knotless.repo/{name}", owner.account.did); if !net().spaces { // Without spaces, a repository not there is not there to anyone. let (status, ..) = looked(&xrpc("getRepo", &repo("none")), Some(&outsider_in)); assert_eq!(status, 404); return; } let url = owner.url("hidden"); assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0); let work = owner.work("w", 1); owner.commit_file(&work, "notes.md", "# Notes\n"); owner.git(&work, &["push", "-q", &url, "main"]); let share = |cmd: &str| owner.cli(&[cmd, &url, &member.account.handle], b"").0; assert_eq!(share("share"), 0); assert_eq!( owner .cli(&["issue", "create", &url, "Private trouble"], b"") .0, 0 ); let member_in = session_cookie(&server, &member.account); let theirs = Some(member_in.as_str()); // A member sees it as a public one is seen, for them alone. let (status, cache, vary, shown) = looked(&xrpc("getRepo", &repo("hidden")), theirs); assert_eq!(status, 200, "{shown}"); assert_eq!(cache, "private, no-store"); assert!(vary.contains("cookie"), "{vary}"); let shown: Value = serde_json::from_str(&shown).unwrap(); assert_eq!(shown["private"], true); assert_eq!(shown["openIssues"], 1); assert_eq!(shown.get("starCount"), None); let tree = looked(&xrpc("getTree", &repo("hidden")), theirs); assert_eq!((tree.0, tree.1.as_str()), (200, "private, no-store")); assert!(tree.3.contains("notes.md"), "{}", tree.3); let issues = xrpc("listItems", &format!("{}&kind=issue", repo("hidden"))); let issues: Value = serde_json::from_str(&looked(&issues, theirs).3).unwrap(); assert_eq!(issues["items"][0]["title"], "Private trouble"); // As is its template, which the new issue page asks for. let template = xrpc("getTemplate", &format!("{}&kind=issue", repo("hidden"))); let template = looked(&template, theirs); assert_eq!( (template.0, template.1.as_str()), (200, "private, no-store") ); // A member reacts: in the space, not their own repo, and counted as // it is read from there, theirs named. let trouble = issues["items"][0]["uri"].as_str().unwrap().to_string(); let at = format!("at://{}/dev.knotless.repo/hidden", owner.account.did); let react = |nsid: &str, content: &str| { let body = json!({"repo": at, "subject": trouble, "content": content}); browse("POST", &xrpc(nsid, ""), theirs, None, Some(body)) }; let (status, _, state) = react("addReaction", "eyes"); assert_eq!(status, 200, "{state}"); assert_eq!(state["reactions"][0]["count"], 1); let eyes = state["reactions"][0]["reaction"] .as_str() .unwrap() .to_string(); // Named in full, in the space. assert!( eyes.starts_with(&format!( "at://{}/space/dev.knotless.space/hidden/{}/dev.knotless.reaction/", owner.account.did, member.account.did )), "{eyes}" ); assert!(member.records("dev.knotless.reaction").is_empty()); let on = format!("{}&uri={trouble}", repo("hidden")); let shown: Value = serde_json::from_str(&looked(&xrpc("getThread", &on), theirs).3).unwrap(); assert_eq!(shown["reactions"][0]["reaction"], eyes.as_str()); let counted: Value = serde_json::from_str(&looked(&xrpc("getReactions", &on), theirs).3).unwrap(); assert_eq!(counted["subjects"][0]["reactions"][0]["content"], "eyes"); let (status, _, left) = react("removeReaction", "eyes"); assert_eq!(status, 200, "{left}"); assert_eq!(left["reactions"], json!([])); let raw = format!( "{}/{}/hidden/raw/main/notes.md", server.base, owner.account.handle ); let file = looked(&raw, theirs); assert_eq!((file.0, file.1.as_str()), (200, "private, no-store")); assert!(file.2.contains("cookie"), "{}", file.2); assert_eq!(file.3, "# Notes\n"); // Its archive, as its files: to members alone, kept by no cache. let archive = format!( "{}/{}/hidden/archive/main.zip", server.base, owner.account.handle ); let zip = looked(&archive, theirs); assert_eq!((zip.0, zip.1.as_str()), (200, "private, no-store")); assert!(zip.2.contains("cookie"), "{}", zip.2); assert!(zip.3.contains("hidden-main/notes.md")); assert_eq!(looked(&archive, None).0, 404); // Its feed, as its files: to members alone, kept by no cache. let feed = format!( "{}/{}/hidden/commits.atom", server.base, owner.account.handle ); let commits = looked(&feed, theirs); assert_eq!((commits.0, commits.1.as_str()), (200, "private, no-store")); assert!(commits.2.contains("cookie"), "{}", commits.2); assert!(commits.3.contains("")); assert_eq!(looked(&feed, None).0, 404); // A public repository, to the same member, is theirs alone to cache, // but may be kept. let open = owner.url("open"); owner.git(&work, &["push", "-q", &open, "main"]); let public = looked(&xrpc("getRepo", &repo("open")), theirs); assert_eq!((public.0, public.1.as_str()), (200, "private, no-cache")); // Nothing of it reaches the index, which has the public one, nor the // feed, which reads only that. let did = &owner.account.did; let feed = xrpc("getFeed", &format!("actor={did}")); wait_until(|| looked(&feed, None).3.contains("\"open\"")); let said = looked(&feed, theirs).3; assert!(!said.contains("hidden"), "{said}"); // Its pushes are among its owner's activity, from this server's index // of its space, to the member, for them alone, and to no one else. let activity = xrpc("getActorActivity", &format!("actor={did}")); let (_, cache, _, said) = looked(&activity, theirs); assert_eq!(cache, "private, no-store"); let said: Value = serde_json::from_str(&said).unwrap(); let pushed = said["repos"].as_array().unwrap(); let hidden = pushed.iter().find(|r| r["name"] == "hidden"); assert_eq!(hidden.unwrap()["days"][0]["pushes"], 1, "{said}"); for cookie in [None, Some(outsider_in.as_str())] { let said = looked(&activity, cookie).3; assert!(!said.contains("hidden"), "{said}"); } // Search finds it for the member, who was shown it, from this // server's index of its space, and for no one else. let search = xrpc("listRepos", "q=hidden"); let (_, cache, _, said) = looked(&search, theirs); assert_eq!(cache, "private, no-store"); assert!(said.contains("\"private\":true"), "{said}"); for cookie in [None, Some(outsider_in.as_str())] { let said = looked(&search, cookie).3; assert!(!said.contains("hidden"), "{search}: {said}"); } // To anyone else, and in any card, it is not there, as one never made // is not. let missing = |cookie: Option<&str>| { let (status, _, _, said) = looked(&xrpc("getRepo", &repo("hidden")), cookie); assert_eq!(status, 404, "{said}"); let said: Value = serde_json::from_str(&said).unwrap(); said["message"].as_str().unwrap().replace("hidden", "?") }; let (_, _, _, none) = looked(&xrpc("getRepo", &repo("never")), None); let none: Value = serde_json::from_str(&none).unwrap(); let none = none["message"].as_str().unwrap().replace("never", "?"); assert_eq!(missing(None), none); assert_eq!(missing(Some(&outsider_in)), none); assert_eq!(looked(&raw, None).0, 404); assert_eq!(looked(&raw, Some(&outsider_in)).0, 404); let card = format!("{}/{}/hidden/card.png", server.base, owner.account.handle); assert_eq!(looked(&card, theirs).0, 404); // Its owner, logged in, has it listed among their repositories, as // does the member who read it, and no one else. let owner_in = session_cookie(&server, &owner.account); let listed = xrpc("getActorRepos", &format!("actor={}", owner.account.did)); let names = |cookie: Option<&str>| { let (_, cache, _, said) = looked(&listed, cookie); let said: Value = serde_json::from_str(&said).unwrap(); let repos = said["repos"].as_array().unwrap().iter(); let names = repos.map(|r| format!("{}{}", r["name"], r["private"])); (cache, names.collect::>()) }; let (cache, mine) = names(Some(&owner_in)); assert_eq!(cache, "private, no-store"); assert!(mine.contains(&"\"hidden\"true".to_string()), "{mine:?}"); let (cache, listed) = names(theirs); assert_eq!(cache, "private, no-store"); assert!(listed.contains(&"\"hidden\"true".to_string()), "{listed:?}"); // Their own list has it apart, as shared with them, to them alone. let own = xrpc("getActorRepos", &format!("actor={}", member.account.did)); let shared = |cookie: Option<&str>| { let said: Value = serde_json::from_str(&looked(&own, cookie).3).unwrap(); said.get("shared").cloned() }; // Another's fork of it, private as it is, shared with the member and // read by them, is listed too, newest first, with what it forked. assert_eq!( owner .cli(&["repo", "share", &url, &forker.account.handle], b"") .0, 0 ); let (code, fork) = forker.cli(&["repo", "fork", &url, "copy"], b""); assert_eq!(code, 0, "{fork}"); let with = member.account.handle.as_str(); assert_eq!(forker.cli(&["repo", "share", fork.trim(), with], b"").0, 0); let copy = format!("repo=at://{}/dev.knotless.repo/copy", forker.account.did); let (status, _, _, said) = looked(&xrpc("getRepo", ©), theirs); assert_eq!(status, 200, "{said}"); let theirs_shared = shared(theirs).unwrap(); assert_eq!(theirs_shared[0]["name"], "copy"); let by = forker.account.did.as_str(); assert_eq!(theirs_shared[0]["owner"]["did"], by); let forked = &theirs_shared[0]["forkOf"]; assert_eq!(forked["did"], owner.account.did.as_str()); assert_eq!(forked["name"], "hidden"); assert_eq!(theirs_shared[1]["name"], "hidden"); assert_eq!(theirs_shared[1]["owner"]["did"], owner.account.did.as_str()); assert_eq!(theirs_shared[1]["private"], true); assert_eq!(theirs_shared[1].get("forkOf"), None); assert_eq!(shared(Some(&outsider_in)), None); assert_eq!(shared(None), None); // The index rebuilt, as for a new version of it, who was proven to read // what is kept: from that alone the member's next look lists both // again, synced as them, before they open either. let path = server.app.ctx.env.cache_dir.join("serve/private.sqlite"); let old = rusqlite::Connection::open(&path).unwrap(); old.execute_batch("PRAGMA user_version = 0").unwrap(); drop(old); knotless_serve::index::Index::open(&path).unwrap(); let kept = server.app.spaces.as_ref().unwrap(); assert!(kept.spaces().unwrap().is_empty()); let them = member.account.did.as_str(); assert_eq!(kept.maybe_member(them).unwrap().len(), 2); // Asked after again every 10 s, as a look each minute does, not on // every look: each asking forgets the member's credentials, which // another under way may be using. As after a restart, whether their PDS // has spaces is not known, so the pages' first ask, `getViewer`, // starts it. let viewer = xrpc("getViewer", ""); let mut asked = std::time::Instant::now() - Duration::from_secs(10); wait_until(|| { if asked.elapsed() >= Duration::from_secs(10) { server.app.discovered.lock().unwrap().clear(); server.app.with_spaces.lock().unwrap().clear(); assert_eq!(looked(&viewer, theirs).0, 200); asked = std::time::Instant::now(); } let names = shared(theirs).unwrap_or_default(); let names = names.as_array().cloned().unwrap_or_default(); let names: Vec<_> = names.iter().map(|r| r["name"].clone()).collect(); names == [json!("copy"), json!("hidden")] }); for cookie in [None, Some(outsider_in.as_str())] { let (_, listed) = names(cookie); assert!(listed.iter().all(|n| !n.contains("hidden")), "{listed:?}"); } let (status, ..) = looked(&xrpc("getRepo", &repo("hidden")), Some(&owner_in)); assert_eq!(status, 200); // Removed from it, a member reads it no more, once its space is asked // again, nor finds it; shared again, they do. let reads = || looked(&xrpc("getRepo", &repo("hidden")), theirs).0; let found = || looked(&search, theirs).3.contains("hidden"); assert_eq!(reads(), 200); assert_eq!(share("unshare"), 0); server.app.reprove.store(0, Ordering::Relaxed); assert_eq!(reads(), 404); assert!(!found()); assert_eq!(share("share"), 0); assert_eq!(reads(), 200); assert!(found()); // Logged out, nor are their credentials kept. let credentials = format!( "config/web/{}/credentials", member.account.did.replace(':', "_") ); assert!(server._sandbox.path(&credentials).exists()); let logout = format!("{}/oauth/logout", server.base); assert_eq!(browse("POST", &logout, theirs, None, None).0, 200); assert_eq!(reads(), 404); assert!(!server._sandbox.path(&credentials).exists()); // Which spaces they were proven to read is kept, listed to no one // while they are out: it alone leads back to a space whose owner never // logs in here, and their next login asks each again. let index = server.app.spaces.clone().unwrap(); assert!(!index.read_of(&member.account.did).unwrap().is_empty()); assert_eq!(shared(None), None); // Back, it is listed to them before they open it, and they read it // again; its owner's account deleted at its PDS, // what they were shown of it goes with the rest, and they read it no // more. let back = session_cookie(&server, &member.account); let names = shared(Some(&back)).unwrap(); assert!( names .as_array() .unwrap() .iter() .any(|r| r["name"] == "hidden") ); let reads = || looked(&xrpc("getRepo", &repo("hidden")), Some(&back)).0; assert_eq!(reads(), 200); assert!(index.repo(&at).unwrap().is_some()); let deleted = json!({"did": owner.account.did}); let res = reqwest::blocking::Client::new() .post(format!( "{}/xrpc/com.atproto.admin.deleteAccount", net().pds )) .header("authorization", format!("Basic {}", admin())) .json(&deleted) .send() .unwrap(); let status = res.status(); assert!(status.is_success(), "{}", res.text().unwrap()); // The index goes first, what read the space for them after. Read // again, its record is asked of a PDS that no longer has the account, // which passes it on to an appview: on this network there is none, so // it can answer 502, not 404. wait_until(|| index.repo(&at).unwrap().is_none()); wait_until(|| matches!(reads(), 404 | 502)); } /// Spoils `did`'s record at `collection`/`rkey` in `space` where the PDS /// keeps it, as a fault of a server's might, leaving its CID as it was: /// its bytes are another record's, which no longer come to it. fn cut_short(did: &str, space: &str, collection: &str, rkey: &str) { use sha2::Digest; let actors = net() .actors .as_ref() .expect("a harness that names its store"); let hash: String = sha2::Sha256::digest(did.as_bytes()) .iter() .map(|b| format!("{b:02x}")) .collect(); let store = std::path::Path::new(actors).join(&hash[..2]).join(did); let db = rusqlite::Connection::open(store.join("store.sqlite")).unwrap(); db.busy_timeout(Duration::from_secs(10)).unwrap(); let other = knotless::drisl::encode(&json!({"$type": collection, "cut": 1})).unwrap(); let spoiled = db .execute( "UPDATE space_record SET value = ?1 WHERE space = ?2 AND collection = ?3 AND rkey = ?4", rusqlite::params![other, space, collection, rkey], ) .unwrap(); assert_eq!(spoiled, 1); } /// The reference PDS's administrator, as dev-env makes it, for basic auth. fn admin() -> String { use base64::Engine; base64::engine::general_purpose::STANDARD.encode("admin:admin-pass") } /// A private repository read here is synced into the server's own index, /// apart from the appview's, with the session of the member who last read /// it, and its pulls, issues and checks read from there, to members alone. /// Its owner's are found as they log in, and listed on their profile from /// there. That member removed, the index forgets it. #[test] fn private_repositories_indexed_here() { let server = gateway_with(None, true); let every = Duration::from_millis(200); server ._rt .spawn(knotless_serve::spaces::follow(server.app.clone(), every)); if !net().spaces { // Nothing private to sync, so nothing is; nor does an owner have // private repositories to list, on a PDS without spaces. std::thread::sleep(every * 2); assert!(server.app.syncers().is_empty()); let owner = person(account()); let owner_in = session_cookie(&server, &owner.account); let did = &owner.account.did; let listed = format!( "{}/xrpc/dev.knotless.getActorRepos?actor={did}", server.base ); let (status, _, _, said) = looked(&listed, Some(&owner_in)); assert_eq!(status, 200, "{said}"); // Which the pages are told, so as to offer Private as not yet // open; and one asked for all the same says why it can't be. let viewer = format!("{}/xrpc/dev.knotless.getViewer", server.base); let spaces = || browse("GET", &viewer, Some(&owner_in), None, None).2["spaces"].clone(); wait_until(|| spaces() == false); let create = format!("{}/xrpc/dev.knotless.createRepo", server.base); let body = json!({"name": "hidden", "private": true}); let (status, _, said) = browse("POST", &create, Some(&owner_in), None, Some(body)); assert_eq!(status, 400, "{said}"); let why = said["message"].as_str().unwrap(); assert!(why.contains("can't hold private repositories yet"), "{why}"); return; } let [owner, member, outsider] = people(); let run = |s: &Sandbox, args: &[&str]| { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out }; let url = owner.url("kept"); run(&owner, &["repo", "create", &url, "--private"]); // One no one reads here, which its owner's profile lists all the same, // and one deleted, which keeps it from none of them. run( &owner, &["repo", "create", &owner.url("found"), "--private"], ); run(&owner, &["repo", "create", &owner.url("gone"), "--private"]); run(&owner, &["repo", "delete", &owner.url("gone"), "--yes"]); let work = owner.work("w", 1); owner.commit_file(&work, "notes.md", "# Notes\n"); owner.git(&work, &["push", "-q", &url, "main"]); run(&owner, &["repo", "share", &url, &member.account.handle]); run(&owner, &["runner", "add", &url, &member.account.handle]); let issue = run(&owner, &["issue", "create", &url, "Private trouble"]); // The member, as its runner, files a check of it into the space. let did = owner.account.did.clone(); let space = knotless::space::uri(&did, "kept"); let rt = tokio::runtime::Runtime::new().unwrap(); let parsed = knotless::url::RepoUrl::parse(&url).unwrap(); let target = rt.block_on(knotless::forge::Target::open(&ctx(&owner), &parsed)); let at = format!("at://{did}/dev.knotless.repo/kept"); let reference = json!({"uri": at, "key": target.unwrap().record.key}); member.put_space_record( &space, "dev.knotless.check", "p1", json!({ "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/main", "commit": owner.git(&work, &["rev-parse", "HEAD"]), "workflow": "test", "status": "success", "startedAt": "2026-10-24T00:00:00.000Z", }), ); let member_in = session_cookie(&server, &member.account); let theirs = Some(member_in.as_str()); let xrpc = |nsid: &str, query: &str| { let base = &server.base; format!("{base}/xrpc/dev.knotless.{nsid}?repo={at}{query}") }; let read = |nsid: &str, query: &str| { let (status, cache, _, said) = looked(&xrpc(nsid, query), theirs); assert_eq!( (status, cache.as_str()), (200, "private, no-store"), "{said}" ); serde_json::from_str::(&said).unwrap() }; let index = server.app.spaces.clone().unwrap(); let followed = || index.spaces().unwrap().iter().any(|(s, _)| *s == space); let syncer = || { let mut syncers = server.app.syncers().into_iter(); syncers.find(|(s, _)| *s == space).map(|(_, did)| did) }; assert!(!followed()); // Read, it is synced at once as the member who read it, and its checks // read from this server's own index. assert_eq!(read("getRepo", "")["openIssues"], 1); assert_eq!(syncer(), Some(member.account.did.clone())); assert!(index.space_synced(&space).unwrap()); wait_until(|| read("getTree", "")["checks"].as_array().unwrap().len() == 1); let check = format!("&uri=at://{}/dev.knotless.check/p1", member.account.did); assert_eq!(read("getCheck", &check)["check"]["workflow"], "test"); assert_eq!(read("listRuns", "")["runs"].as_array().unwrap().len(), 1); // Its mirror is synced again only once its space's sync finds it // written to, as a push does. owner.commit_file(&work, "later.md", "# Later\n"); owner.git(&work, &["push", "-q", &url, "main"]); wait_until(|| read("getTree", "").to_string().contains("later.md")); let thread = |uri: &str| format!("&uri={uri}"); let first = read("getThread", &thread(&issue)); assert_eq!(first["item"]["title"], "Private trouble"); // What is written since is read on from each member's oplog: what is // added, and what is taken back. let again = run(&owner, &["issue", "create", &url, "More trouble"]); // An issue just filed is no thread until the sync finds it: no comments // are listed, not even none. let comments = || { let (_, _, _, said) = looked(&xrpc("getThread", &thread(&again)), theirs); let shown: Value = serde_json::from_str(&said).unwrap(); shown["comments"].as_array().map(Vec::len) }; let said = run(&owner, &["comment", "add", &url, &again, "Noted"]); wait_until(|| comments() == Some(1)); run(&owner, &["comment", "delete", &url, &said]); wait_until(|| comments() == Some(0)); // A repo whose writes no longer check out against the commit its // server vouches for is read whole, and what it took back meanwhile // goes too. let said = run(&owner, &["comment", "add", &url, &again, "Noted again"]); wait_until(|| comments() == Some(1)); // Past any sync still writing where it got to. std::thread::sleep(Duration::from_secs(1)); let (rev, _) = index.space_writer(&space, &did).unwrap().unwrap(); let wrong = vec![0; atproto::space::STATE_BYTES]; index.set_space_writer(&space, &did, &rev, &wrong).unwrap(); run(&owner, &["comment", "delete", &url, &said]); wait_until(|| comments() == Some(0)); assert_eq!(read("getRepo", "")["openIssues"], 2); // A record the member's server keeps cut short, so that it no longer // comes to the CID it was written with, is left out, and the rest of // their repo read all the same. Read whole, the repo does not come to // its commit, so it is read from the start of its oplog instead, which // gives each record's CID apart from its bytes. let theirs_did = member.account.did.clone(); let checks = || { let records = index.space_records(&space, &theirs_did).unwrap(); let checks = records .into_iter() .filter(|(c, _)| c == "dev.knotless.check"); let mut rkeys: Vec = checks.map(|(_, rkey)| rkey).collect(); rkeys.sort(); rkeys }; let file = |rkey: &str| { let check = json!({ "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/main", "commit": owner.git(&work, &["rev-parse", "HEAD"]), "workflow": rkey, "status": "success", "startedAt": "2026-10-24T00:00:00.000Z", }); member.put_space_record(&space, "dev.knotless.check", rkey, check); }; file("p2"); wait_until(|| checks() == ["p1", "p2"]); std::thread::sleep(Duration::from_secs(1)); cut_short(&theirs_did, &space, "dev.knotless.check", "p2"); let (rev, _) = index.space_writer(&space, &theirs_did).unwrap().unwrap(); index .set_space_writer(&space, &theirs_did, &rev, &wrong) .unwrap(); file("p3"); wait_until(|| checks() == ["p1", "p3"]); let p3 = format!("&uri=at://{theirs_did}/dev.knotless.check/p3"); assert_eq!(read("getCheck", &p3)["check"]["workflow"], "p3"); // A member's reaction is in the space, and counted from here at once. let react = format!("{}/xrpc/dev.knotless.addReaction", server.base); let body = json!({"repo": at, "subject": issue, "content": "rocket"}); let (status, _, state) = browse("POST", &react, theirs, None, Some(body)); assert_eq!(status, 200, "{state}"); assert_eq!(state["reactions"][0]["count"], 1); assert!(state["reactions"][0]["reaction"].is_string()); assert!(member.records("dev.knotless.reaction").is_empty()); assert_eq!( read("getThread", &thread(&issue))["reactions"][0]["content"], "rocket" ); // Taken back, it leaves the space, and the count here at once. let unreact = format!("{}/xrpc/dev.knotless.removeReaction", server.base); let body = json!({"repo": at, "subject": issue, "content": "rocket"}); let (status, _, left) = browse("POST", &unreact, theirs, None, Some(body)); assert_eq!(status, 200, "{left}"); assert_eq!(left["reactions"], json!([])); // Shared for writing with someone who never opened it, it names them // a collaborator, so it is in their search as they log in, asked after // for them; refused them, it is not. (Shared only to read, nothing // here names them until they read it.) let newcomer = person(account()); run( &owner, &["account", "delegate", &owner.account.handle, &server.did], ); let write = ["repo", "share", &url, &newcomer.account.handle, "--write"]; run(&owner, &write); let named = || { let shown = server.app.spaces.as_ref().unwrap().repo(&at).unwrap(); shown.unwrap().to_string().contains(&newcomer.account.did) }; wait_until(named); let newcomer_in = session_cookie(&server, &newcomer.account); // Its private one, by owner and name: the test PDS's other accounts make // public repositories called kept too, which the search finds as well. let search = format!("{}/xrpc/dev.knotless.listRepos?q=kept", server.base); let finds = || { server.app.discovered.lock().unwrap().clear(); let said: Value = serde_json::from_str(&looked(&search, Some(&newcomer_in)).3).unwrap(); let mut repos = said["repos"].as_array().unwrap().iter(); repos.any(|r| r["did"] == did.as_str() && r["name"] == "kept" && r["private"] == true) }; wait_until(finds); run( &owner, &["repo", "share", &url, &newcomer.account.handle, "--undo"], ); wait_until(|| !finds()); let owner_in = session_cookie(&server, &owner.account); let listed = format!( "{}/xrpc/dev.knotless.getActorRepos?actor={did}", server.base ); let mine = || { let (status, cache, _, said) = looked(&listed, Some(&owner_in)); assert_eq!((status, cache.as_str()), (200, "private, no-store")); let said: Value = serde_json::from_str(&said).unwrap(); let repos = said["repos"].as_array().unwrap().iter(); let private = repos.filter(|r| r["private"] == true); let mut names: Vec = private.map(|r| r["name"].to_string()).collect(); names.sort(); names }; wait_until(|| mine() == ["\"found\"", "\"kept\""]); // Asked again, what each was counted as written in shows at once, and // is counted again behind it. assert_eq!(mine(), ["\"found\"", "\"kept\""]); let viewer = format!("{}/xrpc/dev.knotless.getViewer", server.base); let (_, _, me) = browse("GET", &viewer, Some(&owner_in), None, None); assert_eq!(me["spaces"], true, "{me}"); let found = knotless::space::uri(&did, "found"); let theirs_too = (found, did.clone()); wait_until(|| server.app.syncers().contains(&theirs_too)); // Nor is it listed to anyone else. let (_, _, _, said) = looked(&listed, theirs); assert!(!said.contains("found"), "{said}"); // What is said on it is heard of by those it concerns, from here: the // owner of what the member said, the member of what came after. let heard = |cookie: &str| { let list = format!("{}/xrpc/dev.knotless.listNotifications", server.base); let (status, _, said) = browse("GET", &list, Some(cookie), None, None); assert_eq!(status, 200, "{said}"); said["notifications"].as_array().unwrap().clone() }; run(&member, &["comment", "add", &url, &issue, "Seen it"]); wait_until(|| heard(&owner_in).len() == 1); let note = &heard(&owner_in)[0]; assert_eq!(note["reason"], "comment"); assert_eq!(note["repo"]["name"], "kept"); assert_eq!(note["title"], "Private trouble"); let unread = format!("{}/xrpc/dev.knotless.getUnreadCount", server.base); let (_, _, count) = browse("GET", &unread, Some(&owner_in), None, None); assert_eq!(count["count"], 1); // An outsider it mentions hears nothing of it, nor that it is there. let thanks = format!("Thanks, and @{}", outsider.account.handle); run(&owner, &["comment", "add", &url, &issue, &thanks]); wait_until(|| heard(&member_in).len() == 1); let outsider_in = session_cookie(&server, &outsider.account); assert!(heard(&outsider_in).is_empty()); // Kept or not, it is not there to anyone else, nor in the appview's // index. for cookie in [None, Some(outsider_in.as_str())] { for asked in [ xrpc("getTree", ""), xrpc("listRuns", ""), xrpc("getCheck", &check), xrpc("getThread", &thread(&issue)), ] { assert_eq!(looked(&asked, cookie).0, 404, "{asked}"); } } assert_eq!(server.app.index.as_ref().unwrap().repo(&at).unwrap(), None); // Its syncer logged out, it is not synced as them. assert_eq!(looked(&xrpc("getRepo", ""), Some(&owner_in)).0, 200); assert_eq!(syncer(), Some(did.clone())); let logout = format!("{}/oauth/logout", server.base); assert_eq!(browse("POST", &logout, Some(&owner_in), None, None).0, 200); assert!(server.app.syncers().is_empty()); read("getRepo", ""); assert_eq!(syncer(), Some(member.account.did.clone())); // Its syncer removed, its sync fails, and it is forgotten. run( &owner, &["repo", "share", &url, &member.account.handle, "--undo"], ); wait_until(|| !followed()); assert!(server.app.syncers().is_empty()); assert!(index.space_records(&space, &did).unwrap().is_empty()); // Deleted as its owner reads it, it is gone from their next look, // though they were shown it within the minute. let owner_in = session_cookie(&server, &owner.account); let owners = || looked(&xrpc("getRepo", ""), Some(&owner_in)).0; assert_eq!(owners(), 200); run(&owner, &["repo", "delete", &url, "--yes"]); wait_until(|| owners() == 404); // Nor does one taken out of it hear of it any more. server.app.reprove.store(0, Ordering::Relaxed); assert!(heard(&member_in).is_empty()); } /// A private repository made from the command line is in this server's /// index without anyone looking, its owner logged in here: the command /// asks its space's authority to tell this server of each write there, /// its first the repository's own record. What is written after is heard /// of the same way, long before the next sweep, and a space deleted is /// forgotten. Only a space's own authority is heard. #[test] fn private_repositories_heard_of() { let server = gateway_with(None, false); // Swept once, at the start; after that, synced only as told. let every = Duration::from_secs(3600); server ._rt .spawn(knotless_serve::spaces::follow(server.app.clone(), every)); let mut owner = person(account()); owner.set("KNOTLESS_APPVIEW", &server.base); let did = owner.account.did.clone(); let tell = |lxm: &str, space: &str, auth: Option<&str>| { let url = format!("{}/xrpc/{lxm}", server.base); let mut req = reqwest::blocking::Client::new().post(url); if let Some(auth) = auth { req = req.header("authorization", format!("Bearer {auth}")); } let res = req.json(&json!({ "space": space })).send().unwrap(); res.status().as_u16() }; let written = knotless_serve::spaces::NOTIFY_WRITE; let space = knotless::space::uri(&did, "heard"); // With no token, or one for this server rather than its appview, it // is not heard. assert_eq!(tell(written, &space, None), 401); let bare = token(&owner, &server.did, written); assert_eq!(tell(written, &space, Some(&bare)), 401); if !net().spaces { return; } let appview = format!("{}{}", server.did, knotless::space::APPVIEW); let told = token(&owner, &appview, written); // Nor of a space not its own, or not a private repository's. let elsewhere = knotless::space::uri("did:plc:elsewhere", "heard"); assert_eq!(tell(written, &elsewhere, Some(&told)), 403); let other = format!("at://{did}/space/app.example.space/heard"); assert_eq!(tell(written, &other, Some(&told)), 403); // Of an owner not logged in here, nothing is followed. assert_eq!(tell(written, &space, Some(&told)), 200); assert!(server.app.syncers().is_empty()); let owner_in = session_cookie(&server, &owner.account); let index = server.app.spaces.clone().unwrap(); let url = owner.url("heard"); let (code, out) = owner.cli(&["repo", "create", &url, "--private"], b""); assert_eq!(code, 0, "{out}"); wait_until(|| index.space_synced(&space).unwrap()); let listed = format!( "{}/xrpc/dev.knotless.getActorRepos?actor={did}", server.base ); let (status, _, _, said) = looked(&listed, Some(&owner_in)); assert_eq!(status, 200, "{said}"); assert!(said.contains("\"heard\""), "{said}"); // An issue filed after is heard of too. let issues = || { let records = index.space_records(&space, &did).unwrap(); records .iter() .filter(|(c, _)| c == "dev.knotless.issue") .count() }; let (code, out) = owner.cli(&["issue", "create", &url, "Heard of"], b""); assert_eq!(code, 0, "{out}"); wait_until(|| issues() == 1); // Deleted, it is forgotten. let (code, out) = owner.cli(&["repo", "delete", &url, "--yes"], b""); assert_eq!(code, 0, "{out}"); let followed = || index.spaces().unwrap().iter().any(|(s, _)| *s == space); wait_until(|| !followed()); let deleted = knotless_serve::spaces::NOTIFY_DELETED; let told = token(&owner, &appview, deleted); assert_eq!(tell(deleted, &space, Some(&told)), 200); // Told of a write there all the same, it is followed again and refused // as deleted, so is not followed again as its owner logs in, though // their PDS may name it still. let told = token(&owner, &appview, written); assert_eq!(tell(written, &space, Some(&told)), 200); wait_until(|| index.gone_spaces().unwrap().contains(&space) && !followed()); } /// Members of a private repository who read and write it only from their /// clients, logged in to nothing here, hear of what is said on it all the /// same: each client shows this server the repository with a delegation /// token as it writes there, and the owner names whom they shared it with /// in its record. Someone it mentions who is not a member hears nothing, /// and a member taken out of it hears no more. #[test] fn private_repositories_heard_of_from_clients() { if !net().spaces { return; } let server = gateway_with(None, true); let [mut owner, mut member, outsider] = people(); owner.set("KNOTLESS_APPVIEW", &server.base); member.set("KNOTLESS_APPVIEW", &server.base); let run = |s: &Sandbox, args: &[&str]| { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out.trim().to_string() }; let heard = |s: &Sandbox| { let url = format!("{}/xrpc/dev.knotless.listNotifications", server.base); let token = token(s, &server.did, "dev.knotless.listNotifications"); let res = reqwest::blocking::Client::new() .get(url) .bearer_auth(token) .send() .unwrap(); assert_eq!(res.status().as_u16(), 200); let said: Value = res.json().unwrap(); said["notifications"].as_array().unwrap().clone() }; let url = owner.url("roadmap"); run(&owner, &["repo", "create", &url, "--private"]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); run(&owner, &["repo", "share", &url, &member.account.handle]); let title = format!( "Next quarter, @{} and @{}", member.account.handle, outsider.account.handle ); let issue = run(&owner, &["issue", "create", &url, &title]); // No one here has a session to sync it with. assert!(server.app.syncers().is_empty()); let told = heard(&member); assert_eq!(told.len(), 1, "{told:?}"); assert_eq!(told[0]["reason"], "mention"); assert_eq!(told[0]["repo"]["name"], "roadmap"); assert_eq!(told[0]["title"], title.as_str()); assert!(heard(&outsider).is_empty()); // A reply mentioning the owner, on their own issue, is heard of once, // as a comment. let reply = format!("Looks right, @{}", owner.account.handle); run(&member, &["comment", "add", &url, &issue, &reply]); let told = heard(&owner); assert_eq!(told.len(), 1, "{told:?}"); assert_eq!(told[0]["reason"], "comment"); assert_eq!(told[0]["author"]["did"], member.account.did.as_str()); assert!(heard(&outsider).is_empty()); // Taken out of it, the member hears no more of it, once what they read // here is to be proven again. run( &owner, &["repo", "share", &url, &member.account.handle, "--undo"], ); server.app.reprove.store(0, Ordering::Relaxed); assert!(heard(&member).is_empty()); } /// A private repository's index stays current when the member it is /// synced as can no longer sync it, as when their session here lapses or /// lacks the scope its space needs: a member reading it syncs it with /// their own credential once it was not synced within a sweep or two, /// rather than every reader seeing it as it was until that member logs in /// again. A reader's sync that fails leaves the read to the index as it /// was, and is not tried again at the next read. #[test] fn private_repositories_synced_by_readers_once_their_syncer_lapses() { if !net().spaces { return; } let server = gateway_with(None, true); let [mut owner, mut member, mut other] = people(); for s in [&mut owner, &mut member, &mut other] { s.set("KNOTLESS_APPVIEW", &server.base); } let run = |s: &Sandbox, args: &[&str]| { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out.trim().to_string() }; let url = owner.url("ledger"); run(&owner, &["repo", "create", &url, "--private"]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); run(&owner, &["repo", "share", &url, &member.account.handle]); run(&owner, &["repo", "share", &url, &other.account.handle]); let did = owner.account.did.clone(); let at = format!("at://{did}/dev.knotless.repo/ledger"); let client = reqwest::blocking::Client::new(); // The owner reads it on the pages, and so it is synced as them. let cookie = session_cookie(&server, &owner.account); let res = client .get(format!( "{}/xrpc/dev.knotless.getRepo?repo={at}", server.base )) .header("cookie", &cookie) .send() .unwrap(); assert_eq!(res.status().as_u16(), 200); let syncers = server.app.syncers(); assert!(syncers.iter().any(|(_, d)| *d == did), "{syncers:?}"); // Once it was not synced lately, the owner's read is answered from the // index as it was, and the space syncs aside, taking in what was // written since. run(&member, &["issue", "create", &url, "Written while synced"]); server.app.last_synced.lock().unwrap().clear(); let owners = || { let res = client .get(format!( "{}/xrpc/dev.knotless.listItems?repo={at}&kind=issue", server.base )) .header("cookie", &cookie) .send() .unwrap(); assert_eq!(res.status().as_u16(), 200); res.json::().unwrap()["items"].to_string() }; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); while !owners().contains("Written while synced") { assert!(std::time::Instant::now() < deadline, "never synced aside"); std::thread::sleep(std::time::Duration::from_millis(100)); } assert!( server .app .last_synced .lock() .unwrap() .contains_key(&knotless::space::uri(&did, "ledger")) ); // Their session here lapses, and they stay its syncer: each sync as // them fails, though not as one refused, which would let it go. let pages = knotless_serve::login::ctx_for(&server.app, &did); knotless::session::remove(&pages, &did); run( &member, &["issue", "create", &url, "Written after the lapse"], ); // The issues a member reads, with a delegation token of theirs, each // spent once; once the space counts as not synced lately. let rt = tokio::runtime::Runtime::new().unwrap(); let c = ctx(&member); let me = rt.block_on(knotless::session::me(&c)).unwrap(); let space = knotless::space::uri(&did, "ledger"); let stale = || server.app.last_synced.lock().unwrap().clear(); let listed = || { let token = rt.block_on(knotless::space::delegation(&c, &space, &me)); let res = client .get(format!( "{}/xrpc/dev.knotless.listItems?repo={at}&kind=issue", server.base )) .header(knotless::space::DELEGATION_HEADER, token.unwrap()) .send() .unwrap(); assert_eq!(res.status().as_u16(), 200); let listed: Value = res.json().unwrap(); let mut titles: Vec = listed["items"] .as_array() .unwrap() .iter() .filter_map(|i| i["title"].as_str().map(String::from)) .collect(); titles.sort(); titles }; // Once it was not synced lately, a member's read syncs it as them. stale(); assert_eq!( listed(), ["Written after the lapse", "Written while synced"] ); // A member whose repo cannot be read fails the sync: one who writes, // then is taken down where they live. The read is answered from the // index as it was, and the sync that failed counts as one made. run( &other, &["issue", "create", &url, "Written before the takedown"], ); let takedown = |applied: bool| { let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": other.account.did}); let res = client .post(format!( "{}/xrpc/com.atproto.admin.updateSubjectStatus", net().pds )) .header("authorization", format!("Basic {}", admin())) .json(&json!({"subject": subject, "takedown": {"applied": applied}})) .send() .unwrap(); assert!(res.status().is_success(), "{}", res.text().unwrap()); }; takedown(true); stale(); assert_eq!( listed(), ["Written after the lapse", "Written while synced"] ); assert!(server.app.last_synced.lock().unwrap().contains_key(&space)); // Restored, it is taken in at the next read that finds it stale. takedown(false); stale(); assert_eq!( listed(), [ "Written after the lapse", "Written before the takedown", "Written while synced" ] ); } /// From the repository's settings, its owner sees its runners' keys, and /// leaves, lists and takes back secrets sealed to them in the browser, /// with the pages' login rather than a service token. #[test] fn secrets_from_the_pages() { let server = gateway_with(None, true); let mut owner = person(account()); owner.set("KNOTLESS_APPVIEW", &server.base); let runner = person(account()); let url = owner.url("sealing"); let work = owner.work("w", 1); owner.commit_file(&work, "f", "f\n"); owner.git(&work, &["push", "-q", &url, "main"]); let (code, out) = owner.cli(&["runner", "add", &url, &runner.account.handle], b""); assert_eq!(code, 0, "{out}"); let (od, rd) = (owner.account.did.clone(), runner.account.did.clone()); let key = owner.record("dev.knotless.repo", "sealing")["key"] .as_str() .unwrap() .to_string(); let repo = format!("at://{od}/dev.knotless.repo/sealing"); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let mine = session_cookie(&server, &owner.account); let theirs = session_cookie(&server, &runner.account); let (me, them) = (Some(mine.as_str()), Some(theirs.as_str())); // The key the runner publishes, once indexed, is beside it. let dir = tempfile::tempdir().unwrap(); let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap(); let did_key = keys.public().did_key(); let published = json!({ "$type": knotless::sealed::KEY_NSID, "key": did_key, "createdAt": "2026-10-30T00:00:00.000Z", }); runner.put_record( knotless::sealed::KEY_NSID, knotless::sealed::KEY_RKEY, published, ); let listed = || { let url = format!("{}?repo={repo}", xrpc("listRunners")); browse("GET", &url, None, None, None).2 }; wait_until(|| listed()["keys"].is_array()); assert_eq!(listed()["keys"], json!([{"runner": rd, "key": did_key}])); // Its owner leaves one, sealed as the browser seals it, and lists it. let bound = knotless::sealed::Bound { owner: &od, repo: &key, runner: &rd, name: "TOKEN", refs: &[], }; let sealed = knotless::sealed::seal(&keys.public(), &bound, b"s3cret").unwrap(); use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD.encode(&sealed); let secret = json!({ "repo": key, "runner": rd, "name": "TOKEN", "key": did_key, "sealed": {"$bytes": bytes}, }); let write = |nsid: &str, cookie: Option<&str>, origin: Option<&str>, body: &Value| { browse("POST", &xrpc(nsid), cookie, origin, Some(body.clone())) }; assert_eq!(write("putSecret", None, None, &secret).0, 401); let elsewhere = Some("https://elsewhere.example"); assert_eq!(write("putSecret", me, elsewhere, &secret).0, 403); let (code, _, said) = write("putSecret", me, None, &secret); assert_eq!(code, 200, "{said}"); let list = |cookie| { let url = format!("{}?owner={od}&repo={key}", xrpc("listSecrets")); browse("GET", &url, cookie, None, None) }; let (code, _, all) = list(me); assert_eq!(code, 200, "{all}"); assert_eq!(all["secrets"][0]["name"], "TOKEN"); assert!(all["secrets"][0]["sealed"].is_null(), "{all}"); // Logged in as the runner, it is listed sealed, to open. let (_, _, its) = list(them); assert!(its["secrets"][0]["sealed"]["$bytes"].is_string(), "{its}"); assert_eq!(list(None).0, 401); // And takes it back. let gone = json!({"repo": key, "name": "TOKEN"}); let (code, _, said) = write("deleteSecret", me, None, &gone); assert_eq!((code, &said["deleted"]), (200, &json!(1)), "{said}"); assert_eq!(list(me).2, json!({"secrets": []})); }