//! What the pages write to repositories, as the account logged in to //! them, against a reference PDS: making them, forking them, and what an //! owner sets in their settings. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::*; use knotless_serve::gateway::Gateway; use knotless_serve::sites::{Sites, hosted}; use knotless_serve::{App, router}; use serde_json::{Value, json}; use std::sync::Arc; use std::time::Duration; struct Server { base: String, /// The gateway's DID, which owners delegate their writes to. did: String, app: Arc, _rt: tokio::runtime::Runtime, _proxy: TlsProxy, _sandbox: Sandbox, } /// A gateway that logs people in to its pages, and an appview whose index /// follows the PDS's firehose from now on. fn server() -> Server { let mut s = Sandbox::new(); s.set("KNOTLESS_PASSWORD", ""); 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}"); // The pages find a repository's pulls here, as a branch renamed moves them. s.set("KNOTLESS_APPVIEW", &base); let mut gw = Gateway::new(&base).unwrap(); // Accounts are made on the harness's PDS, not anyone's. gw.sign_up_pds = net().pds.clone(); // Authorization servers reach an OAuth client over https. let proxy = tls_proxy(port); gw.oauth_url = proxy.url.clone(); let did = gw.did.clone(); let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap(); let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index)); // Repositories' sites, on their domain, as any host the proxy says. let sites = Sites::new(app.clone(), "knotless.page".into(), format!("{base}/")); let router = hosted(router(app.clone()), sites); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); let firehose = net().pds.replace("http://", "ws://"); rt.spawn(knotless_serve::firehose::watch(app.clone(), firehose)); Server { base, did, app, _rt: rt, _proxy: proxy, _sandbox: s, } } /// A sandbox logged in as `account` with a password, so the command line /// acts as it, and a browser that approves logins as it. fn person(account: Account) -> Sandbox { let mut s = Sandbox::with(account); s.set("KNOTLESS_DID_WEB_SCHEME", "http"); s.set("KNOTLESS_PASSWORD", ""); s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), "")); let password = s.account.password.clone(); let handle = s.account.handle.clone(); let login = ["login", &handle, "--password-stdin"]; assert_eq!(s.cli(&login, password.as_bytes()).0, 0); s } /// A request to `url` as a browser on the site sends it, with `cookie` if /// any: its status, the cookies it sets, and its answer. An XRPC answer is /// kept too, where the run names a directory for them, for harness/ /// answers.mjs to hold to its lexicon. fn browse( method: &str, url: &str, cookie: 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(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); let path = url.split_once("/xrpc/").map(|(_, p)| format!("/xrpc/{p}")); if let (Some(dir), Some(path), 200) = (std::env::var_os("KNOTLESS_ANSWERS"), path, status) { keep(&dir, &path, &answer); } (status, set, answer) } /// Adds the answer at `path` to this process's file of them under `dir`. fn keep(dir: &std::ffi::OsStr, path: &str, answer: &Value) { use std::io::Write; static FILE: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _held = FILE.lock().unwrap(); std::fs::create_dir_all(dir).unwrap(); let file = std::path::Path::new(dir).join(format!("pages-{}.jsonl", std::process::id())); let mut out = std::fs::OpenOptions::new() .create(true) .append(true) .open(file) .unwrap(); writeln!(out, "{}", json!({ "path": path, "body": answer })).unwrap(); } /// Logs `who` in to the pages of `server`, approving in a browser: the /// cookie naming them, as the browser sends it back. fn log_in(server: &Server, who: &Account) -> String { let start = json!({"handle": who.handle, "back": "/"}); log_in_as(server, start, who, "").unwrap() } /// Starts a login to the pages of `server` with `start`, and approves it /// in a browser as `who`, with `extra` its flags: the cookie naming them, /// if the site set one. fn log_in_as(server: &Server, start: Value, who: &Account, extra: &str) -> Option { let login = format!("{}/oauth/login", server.base); let (status, set, answer) = browse("POST", &login, None, Some(start)); assert_eq!(status, 200, "{answer}"); let started = set[0].split(';').next().unwrap().to_string(); 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) ); let set = String::from_utf8(out.stdout).unwrap(); let session = set.lines().find(|c| c.starts_with("knotless_session=")); session.map(|c| c.split(';').next().unwrap().to_string()) } impl Server { fn xrpc(&self, nsid: &str) -> String { format!("{}/xrpc/dev.knotless.{nsid}", self.base) } /// The procedure `nsid` given `input`, as the browser holding `cookie`. fn call(&self, nsid: &str, cookie: Option<&str>, input: Value) -> (u16, Value) { let (status, _, answer) = browse("POST", &self.xrpc(nsid), cookie, Some(input)); (status, answer) } /// `getRepo`'s answer for `did`'s repository `name`. fn repo(&self, did: &str, name: &str) -> Value { let url = format!( "{}?repo=at://{did}/dev.knotless.repo/{name}", self.xrpc("getRepo") ); let (status, _, answer) = browse("GET", &url, None, None); assert_eq!(status, 200, "{answer}"); answer } } /// A name the form checks as it is typed, still being looked for when the /// repository it names is made, keeps no one from the repository after: /// the reads its page asks for at once find it. #[test] fn a_name_checked_as_its_repository_is_made() { let server = server(); let maker = person(account()); let cookie = log_in(&server, &maker.account); let me = Some(cookie.as_str()); let handle = maker.account.handle.clone(); for i in 0..20u64 { let name = format!("raced{i}"); let repo = format!("at://{handle}/dev.knotless.repo/{name}"); let read = |nsid: &str| { let url = format!("{}?repo={repo}", server.xrpc(nsid)); browse("GET", &url, me, None).0 }; let read = &read; std::thread::scope(|s| { let made = s.spawn(|| server.call("createRepo", me, json!({ "name": name })).0); // The check, a moment into the making. std::thread::sleep(Duration::from_millis(i * 3)); let checked = s.spawn(|| read("getRepo")); assert_eq!(made.join().unwrap(), 200); let page = ["getRepo", "getTree", "listBranches", "listTags"]; let page = page.map(|nsid| s.spawn(move || (nsid, read(nsid)))); let found = page.map(|asked| asked.join().unwrap()); assert!( found.iter().all(|(_, status)| *status == 200), "{name}: {found:?}" ); checked.join().unwrap(); }); } } /// An account taken down cannot log in, and the cookie it holds names no /// one until it is restored. #[test] fn a_taken_down_account_is_logged_out() { let server = server(); let who = person(account()); let cookie = log_in(&server, &who.account); let me = Some(cookie.as_str()); let did = who.account.did.clone(); let (status, _) = server.call("createRepo", me, json!({"name": "before"})); assert_eq!(status, 200); let index = server.app.index.clone().unwrap(); let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": did}); let down = |applied| index.take_down(&server.did, &did, &subject, applied); down(true).unwrap(); let (status, _) = server.call("createRepo", me, json!({"name": "during"})); assert_eq!(status, 401); let start = json!({"handle": who.account.handle, "back": "/"}); let login = format!("{}/oauth/login", server.base); // Refused as text, not JSON: the pages show it as it is. let refused = reqwest::blocking::Client::new().post(&login).json(&start); let refused = refused.send().unwrap(); assert_eq!(refused.status().as_u16(), 403); let why = refused.text().unwrap(); assert!(why.contains("taken down here"), "{why}"); // Restored, the same cookie names it again. down(false).unwrap(); let (status, _) = server.call("createRepo", me, json!({"name": "after"})); assert_eq!(status, 200); } /// Someone with no account makes one on the PDS the site sends people to, /// on its sign-up page, and comes back logged in as it. An account taken /// down that signs in there instead is still refused. #[test] fn making_an_account_to_log_in() { let server = server(); let start = json!({"create": true, "back": "/"}); // Made once in this binary, so its tag sets it apart. let handle = format!("n{}.test", *common::TAG); let made = Account { handle: handle.clone(), did: String::new(), password: format!("{handle}-password"), }; let cookie = log_in_as(&server, start.clone(), &made, "--create").unwrap(); let viewer = server.xrpc("getViewer"); let (status, _, shown) = browse("GET", &viewer, Some(&cookie), None); assert_eq!(status, 200, "{shown}"); let did = shown["actor"]["did"].as_str().unwrap(); assert!(did.starts_with("did:plc:"), "{shown}"); // Its session is the pages', kept under the account the PDS named. let ctx = knotless_serve::login::ctx_for(&server.app, did); let kept = knotless::session::load(&ctx, did).unwrap(); assert_eq!(kept.handle, handle); let who = account(); let index = server.app.index.clone().unwrap(); let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": who.did}); index .take_down(&server.did, &who.did, &subject, true) .unwrap(); assert_eq!(log_in_as(&server, start, &who, ""), None); } /// The app's login, through the browser it opens, as `who`: where it /// came back to. fn app_log_in(server: &Server, who: &Account, challenge: &str, extra: &str) -> String { let client = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); let url = format!("{}/oauth/app-login", server.base); let ask = [("handle", who.handle.as_str()), ("challenge", challenge)]; let res = client.get(&url).query(&ask).send().unwrap(); assert_eq!(res.status().as_u16(), 303); let headers = res.headers(); let started = headers["set-cookie"].to_str().unwrap(); let started = started.split(';').next().unwrap(); let pds = headers["location"].to_str().unwrap(); let command = format!("{} --cookie {started} {pds}", 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) ); let out = String::from_utf8(out.stdout).unwrap(); let back = out.lines().find_map(|l| l.strip_prefix("app: ")); back.unwrap().to_string() } /// The app logs in through a browser, comes back with a code only its /// verifier can trade, once, and with what it gets asks and writes as a /// page's cookie does. A login refused gives it nothing, and nor does an /// account taken down since. #[test] fn the_app_logs_in_with_a_code_only_it_can_trade() { let server = server(); let who = person(account()); let verifier = "the-app-keeps-this-to-itself-and-sends-it-once"; let challenge = knotless::oauth::sha256(verifier); let trade = format!("{}/oauth/app-token", server.base); let code_from = |back: &str| back.split_once("?code=").unwrap().1.to_string(); let swap = |code: &str, verifier: &str| { let ask = json!({"code": code, "verifier": verifier}); browse("POST", &trade, None, Some(ask)) }; let start = format!("{}/oauth/app-login?handle=a&challenge=short", server.base); assert_eq!(browse("GET", &start, None, None).0, 400); let refused = app_log_in(&server, &who.account, &challenge, "--reject"); let where_to = knotless_serve::login::APP_RETURN; assert_eq!(refused, format!("{}{where_to}?error=refused", server.base)); // A browser with no app to take over is told so, and iOS can see it may. let (status, _, _) = browse("GET", &format!("{}{where_to}", server.base), None, None); assert_eq!(status, 200); let named = format!("{}/.well-known/apple-app-site-association", server.base); let (status, _, said) = browse("GET", &named, None, None); assert_eq!(status, 200); // What it answers is what the app is built to ask: without both, a browser // session won't open for it, or won't come back. The file on Apple's CDN // follows this one. let project = include_str!("../../../ios/project.yml"); let entitlements = include_str!("../../../ios/Knotless/Knotless.entitlements"); let setting = |key: &str| { project .lines() .find_map(|line| line.trim().strip_prefix(key)) .unwrap() }; let app = format!( "{}.{}", setting("DEVELOPMENT_TEAM: "), setting("PRODUCT_BUNDLE_IDENTIFIER: ") ); let details = &said["applinks"]["details"][0]; assert_eq!(details["appIDs"][0], json!(app)); assert_eq!(details["components"][0]["/"], json!(where_to)); assert_eq!(said["webcredentials"]["apps"][0], json!(app)); for service in ["applinks:knotless.dev", "webcredentials:knotless.dev"] { assert!( project.contains(service) && entitlements.contains(service), "{service}" ); } // A wrong verifier spends the code, for whoever stole it. let code = code_from(&app_log_in(&server, &who.account, &challenge, "")); assert_eq!(swap(&code, "not-the-verifier").0, 400); assert_eq!(swap(&code, verifier).0, 400); let code = code_from(&app_log_in(&server, &who.account, &challenge, "")); let (status, set, answer) = swap(&code, verifier); assert_eq!(status, 200, "{answer}"); assert_eq!(set, Vec::::new()); assert_eq!(answer["did"], json!(who.account.did)); assert_eq!(swap(&code, verifier).0, 400); let cookie = format!("knotless_session={}", answer["token"].as_str().unwrap()); let me = Some(cookie.as_str()); let (status, _, shown) = browse("GET", &server.xrpc("getViewer"), me, None); assert_eq!(status, 200, "{shown}"); assert_eq!(shown["actor"]["did"], json!(who.account.did)); let (status, made) = server.call("createRepo", me, json!({"name": "from-the-app"})); assert_eq!(status, 200, "{made}"); let code = code_from(&app_log_in(&server, &who.account, &challenge, "")); let index = server.app.index.clone().unwrap(); let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": who.account.did}); let down = |applied| index.take_down(&server.did, &who.account.did, &subject, applied); down(true).unwrap(); assert_eq!(swap(&code, verifier).0, 400); down(false).unwrap(); } /// Someone logged in to the pages makes a repository there, and forks /// another, as `knotless repo create` and `knotless repo fork` do; and only someone /// logged in can. #[test] fn making_repositories_from_the_pages() { // Started first, so its index sees the repositories made after. let server = server(); let owner = person(account()); let source = owner.url("source"); let work = owner.work("w", 2); owner.git(&work, &["push", "-q", &source, "main"]); let maker = person(account()); let cookie = log_in(&server, &maker.account); let me = Some(cookie.as_str()); let did = maker.account.did.clone(); // Made with what the form asks for, and shown at once. let asked = json!({"name": " made ", "description": "From the pages", "defaultBranch": "trunk"}); let (status, made) = server.call("createRepo", me, asked); assert_eq!(status, 200, "{made}"); let uri = format!("at://{did}/dev.knotless.repo/made"); assert_eq!(made, json!({"uri": uri, "name": "made"})); let record = maker.record("dev.knotless.repo", "made"); assert_eq!(record["description"], "From the pages"); assert_eq!(record["defaultBranch"], "trunk"); assert_eq!(server.repo(&did, "made")["description"], "From the pages"); // Blank fields are left out, and the default branch is main. let (status, _) = server.call( "createRepo", me, json!({"name": "plain", "description": ""}), ); assert_eq!(status, 200); let plain = maker.record("dev.knotless.repo", "plain"); assert_eq!( (plain.get("description"), &plain["defaultBranch"]), (None, &json!("main")) ); // Not twice, nor under a name no repository can have. let (status, again) = server.call("createRepo", me, json!({"name": "made"})); assert_eq!(status, 400); assert!( again["message"] .as_str() .unwrap() .contains("already exists"), "{again}" ); let (status, bad) = server.call("createRepo", me, json!({"name": "no good"})); assert_eq!(status, 400); assert!( bad["message"] .as_str() .unwrap() .contains("no repository's name"), "{bad}" ); // Only as someone logged in. let (status, _) = server.call("createRepo", None, json!({"name": "nobody"})); assert_eq!(status, 401); // A fork takes the repository's name, or one given, and every branch. let theirs = format!("at://{}/dev.knotless.repo/source", owner.account.did); let (status, forked) = server.call("forkRepo", me, json!({ "repo": theirs })); assert_eq!(status, 200, "{forked}"); assert_eq!(forked["name"], "source"); let shown = server.repo(&did, "source"); assert_eq!(shown["forkOf"]["name"], "source"); assert_eq!(shown["defaultBranch"], "main"); let named = json!({"repo": theirs, "name": "copy"}); assert_eq!(server.call("forkRepo", me, named).1["name"], "copy"); let (status, twice) = server.call("forkRepo", me, json!({ "repo": theirs })); assert_eq!(status, 400, "{twice}"); let missing = format!("at://{}/dev.knotless.repo/missing", owner.account.did); assert_eq!( server.call("forkRepo", me, json!({ "repo": missing })).0, 404 ); assert_eq!( server.call("forkRepo", None, json!({ "repo": theirs })).0, 401 ); } impl Server { /// `getRepoSettings`' answer for `did`'s repository `name`. fn settings(&self, did: &str, name: &str) -> Value { let url = format!( "{}?repo=at://{did}/dev.knotless.repo/{name}", self.xrpc("getRepoSettings") ); let (status, _, answer) = browse("GET", &url, None, None); assert_eq!(status, 200, "{answer}"); answer } } /// An owner changes their repository's settings from the pages, as the /// command line does: what it says it is and its default branch, its name, /// its packs, an offer to move it, which the account offered it takes up, /// and deleting it. Anyone else is refused, whatever the pages show. #[test] fn owners_change_their_settings_from_the_pages() { let server = server(); let owner = person(account()); let other = person(account()); let kept = owner.url("kept"); let work = owner.work("w", 2); owner.git(&work, &["push", "-q", &kept, "main", "main:topic"]); let triager = ["triager", "add", &kept, &other.account.handle, "kind"]; assert_eq!(owner.cli(&triager, b"").0, 0); let (did, theirs) = (owner.account.did.clone(), other.account.did.clone()); let mine = log_in(&server, &owner.account); let me = Some(mine.as_str()); let them = log_in(&server, &other.account); let not_me = Some(them.as_str()); let uri = |name: &str| format!("at://{did}/dev.knotless.repo/{name}"); // What it has set, from its record. let set = server.settings(&did, "kept"); assert_eq!(set["uri"], uri("kept")); assert_eq!(set["defaultBranch"], "main"); assert_eq!((&set["rules"], &set["labels"]), (&json!([]), &json!([]))); assert_eq!(set["collaborators"], json!([])); assert_eq!(set["triagers"][0]["actor"]["did"], theirs); assert_eq!(set["triagers"][0]["scopes"], json!([{"name": "kind"}])); assert_eq!(set.get("transfer"), None); // What it takes up, apart from its settings, which no longer count it; // and the same again from what was counted, as nothing of it moved. assert_eq!(set.get("storage"), None); let storage = |name: &str| { let url = format!("{}?repo={}", server.xrpc("getRepoStorage"), uri(name)); let (status, _, answer) = browse("GET", &url, None, None); assert_eq!(status, 200, "{answer}"); answer }; let counted = storage("kept"); assert!(counted["packs"].as_u64().unwrap() >= 1, "{counted}"); assert_eq!(storage("kept"), counted); // Its description and default branch, by the owner alone. let links = json!([{"url": "https://kept.example"}, {"url": "https://kept.example/docs", "title": "Docs"}]); let topics = json!([{"name": "rust"}, {"name": "cli"}]); let change = json!({"repo": uri("kept"), "description": "Now said", "links": links, "topics": topics, "defaultBranch": "topic"}); assert_eq!(server.call("updateRepo", None, change.clone()).0, 401); let (status, refused) = server.call("updateRepo", not_me, change.clone()); assert_eq!(status, 403, "{refused}"); assert!( refused["message"].as_str().unwrap().contains("owner"), "{refused}" ); let (status, changed) = server.call("updateRepo", me, change); assert_eq!(status, 200, "{changed}"); assert_eq!( changed, json!({"description": "Now said", "links": links, "topics": topics, "defaultBranch": "topic"}) ); let shown = server.repo(&did, "kept"); let said = (&shown["description"], &shown["defaultBranch"]); assert_eq!(said, (&json!("Now said"), &json!("topic"))); assert_eq!((&shown["links"], &shown["topics"]), (&links, &topics)); assert_eq!(shown.get("collaborators"), None); let set = server.settings(&did, "kept"); assert_eq!((&set["links"], &set["topics"]), (&links, &topics)); // Listed among those about one of them, and not another. let about = |topic: &str| { let url = format!("{}?topic={topic}", server.xrpc("listRepos")); let (status, _, listed) = browse("GET", &url, None, None); assert_eq!(status, 200, "{listed}"); let names = listed["repos"].as_array().unwrap().iter(); names .map(|r| r["name"].as_str().unwrap().to_string()) .collect::>() }; assert!(about("rust").contains(&"kept".to_string())); assert!(!about("haskell").contains(&"kept".to_string())); let untopical = json!({"repo": uri("kept"), "topics": [{"name": "no spaces"}]}); assert_eq!(server.call("updateRepo", me, untopical).0, 400); let nowhere = json!({"repo": uri("kept"), "links": [{"url": "kept.example"}]}); assert_eq!(server.call("updateRepo", me, nowhere).0, 400); let nowhere = json!({"repo": uri("kept"), "defaultBranch": "nowhere"}); assert_eq!(server.call("updateRepo", me, nowhere).0, 400); // Its packs folded. let kept_uri = json!({ "repo": uri("kept") }); let (status, compacted) = server.call("compactRepo", me, kept_uri.clone()); assert_eq!(status, 200, "{compacted}"); assert!(compacted["packs"].as_u64().unwrap() >= 1, "{compacted}"); assert_eq!(server.call("compactRepo", not_me, kept_uri.clone()).0, 403); // Counted again at once, before the firehose tells of it. let (before, folded) = (&compacted["packs"], &compacted["folded"]); let (before, folded) = (before.as_u64().unwrap(), folded.as_u64().unwrap()); let now = before - folded + u64::from(folded > 0); assert_eq!(storage("kept")["packs"], now, "{compacted}"); // Archived, which its page says, then unarchived; only by its owner, // and only once each way. let archive = json!({ "repo": uri("kept") }); let unarchive = json!({"repo": uri("kept"), "unarchive": true}); assert_eq!(server.call("archiveRepo", not_me, archive.clone()).0, 403); let (status, archived) = server.call("archiveRepo", me, archive.clone()); assert_eq!(status, 200, "{archived}"); assert!(archived["archivedAt"].is_string(), "{archived}"); assert!(server.repo(&did, "kept")["archivedAt"].is_string()); assert_eq!(server.call("archiveRepo", me, archive).0, 400); let (status, back) = server.call("archiveRepo", me, unarchive.clone()); assert_eq!((status, back), (200, json!({}))); assert_eq!(server.repo(&did, "kept").get("archivedAt"), None); assert_eq!(server.call("archiveRepo", me, unarchive).0, 400); // Offered to another account, withdrawn, and offered again under // another name, which that account sees on the repository's page. let offer = json!({"repo": uri("kept"), "to": other.account.handle}); let (status, offered) = server.call("transferRepo", me, offer); assert_eq!(status, 200, "{offered}"); let to = (&offered["transfer"]["did"], &offered["transfer"]["name"]); assert_eq!(to, (&json!(theirs), &json!("kept"))); assert_eq!(server.settings(&did, "kept")["transfer"]["name"], "kept"); let (status, withdrawn) = server.call("transferRepo", me, kept_uri.clone()); assert_eq!((status, withdrawn), (200, json!({}))); let nobody = json!({"repo": uri("kept"), "to": "nobody.invalid"}); assert_eq!(server.call("transferRepo", me, nobody).0, 400); let offer = json!({"repo": uri("kept"), "to": theirs, "name": "taken"}); assert_eq!(server.call("transferRepo", not_me, offer.clone()).0, 403); assert_eq!(server.call("transferRepo", me, offer).0, 200); let page = |cookie: Option<&str>| { let url = format!("{}?repo={}", server.xrpc("getRepo"), uri("kept")); browse("GET", &url, cookie, None).2 }; assert_eq!(page(not_me)["viewer"]["transfer"]["name"], "taken"); assert_eq!(page(me)["viewer"].get("transfer"), None); // Taken up by that account alone, under the name offered. assert_eq!(server.call("acceptTransfer", me, kept_uri.clone()).0, 400); let (status, taken) = server.call("acceptTransfer", not_me, kept_uri); assert_eq!(status, 200, "{taken}"); let there = format!("at://{theirs}/dev.knotless.repo/taken"); assert_eq!(taken, json!({"uri": there, "name": "taken"})); assert_eq!(server.repo(&theirs, "taken")["description"], "Now said"); // Renamed, the old name leads to the new. assert_eq!( owner.cli(&["repo", "create", &owner.url("named")], b"").0, 0 ); let rename = json!({"repo": uri("named"), "name": "renamed"}); assert_eq!(server.call("renameRepo", not_me, rename.clone()).0, 403); let (status, renamed) = server.call("renameRepo", me, rename); assert_eq!(status, 200, "{renamed}"); assert_eq!(renamed, json!({"uri": uri("renamed"), "name": "renamed"})); assert_eq!(server.repo(&did, "named")["name"], "renamed"); let again = json!({"repo": uri("renamed"), "name": "renamed"}); assert_eq!(server.call("renameRepo", me, again).0, 400); // Deleted, by the owner alone. let gone = json!({ "repo": uri("renamed") }); assert_eq!(server.call("deleteRepo", not_me, gone.clone()).0, 403); let (status, deleted) = server.call("deleteRepo", me, gone.clone()); assert_eq!((status, deleted), (200, json!({"uri": uri("renamed")}))); assert!(owner.record("dev.knotless.repo", "renamed").is_null()); assert_eq!(server.call("deleteRepo", me, gone).0, 404); } /// An owner manages a repository's branches, which are protected, its /// labels, and who may push to it or label it, from the pages' settings, as /// the command line does; anyone else is refused. #[test] fn owners_manage_branches_labels_and_access() { let server = server(); let mut owner = person(account()); owner.set("KNOTLESS_APPVIEW", &server.base); let other = person(account()); let helper = person(account()); let managed = owner.url("managed"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &managed, "main"]); let mine = log_in(&server, &owner.account); let me = Some(mine.as_str()); let them = log_in(&server, &other.account); let not_me = Some(them.as_str()); let repo = format!("at://{}/dev.knotless.repo/managed", owner.account.did); let with = |more: Value| { let mut input = json!({ "repo": repo }); let more = more.as_object().unwrap().clone(); input.as_object_mut().unwrap().extend(more); input }; // Branches, made, renamed and deleted; the default one stays. let (status, made) = server.call( "createBranch", me, with(json!({"name": "topic", "from": "main"})), ); assert_eq!(status, 200, "{made}"); assert_eq!(made["name"], "topic"); assert_eq!(made["sha"].as_str().map(str::len), Some(40)); let again = with(json!({"name": "topic"})); assert_eq!(server.call("createBranch", me, again).0, 400); // Anyone else pushes through the gateway, which takes no one but // collaborators, and there is none yet. let theirs = with(json!({"name": "theirs"})); assert_eq!(server.call("createBranch", not_me, theirs.clone()).0, 400); assert_eq!(server.call("createBranch", None, theirs.clone()).0, 401); let rename = with(json!({"name": "topic", "to": "twig"})); let (status, renamed) = server.call("renameBranch", me, rename); assert_eq!((status, renamed), (200, json!({"name": "twig"}))); let (_, refs) = owner.cli(&["repo", "refs", &managed], b""); assert!( refs.contains("heads/twig") && !refs.contains("heads/topic"), "{refs}" ); // The command line renames one the same way. let bough = ["branch", "rename", &managed, "twig", "bough"]; let said = "renamed twig to bough"; assert_eq!(owner.cli(&bough, b""), (0, said.to_string())); let gone = with(json!({"name": "bough"})); let (status, deleted) = server.call("deleteBranch", me, gone); assert_eq!((status, deleted), (200, json!({"name": "bough"}))); let main = with(json!({"name": "main"})); assert_eq!(server.call("deleteBranch", me, main).0, 400); // Protected, and no longer, by the owner alone. let protect = with(json!({"branch": "main", "merges": true})); let (status, rules) = server.call("protectBranch", me, protect); assert_eq!(status, 200, "{rules}"); let held = json!([{"branch": "main", "history": "merges"}]); assert_eq!(rules, json!({ "rules": held })); let set = server.settings(&owner.account.did, "managed"); assert_eq!(set["rules"], held); assert_eq!(set.get("gates"), None); // Its gates, named twice, are shown once, as accounts. let gate = json!({ "did": other.account.did }); let reviews = json!({ "gates": [gate, gate] }); let gated = with(json!({"branch": "main", "reviews": reviews})); assert_eq!(server.call("protectBranch", me, gated).0, 200); let set = server.settings(&owner.account.did, "managed"); let gates = set["gates"].as_array().unwrap(); assert_eq!(gates.len(), 1, "{set}"); assert_eq!(gates[0]["did"], other.account.did.as_str()); let main = with(json!({"branch": "main"})); assert_eq!(server.call("protectBranch", not_me, main.clone()).0, 403); let (status, rules) = server.call("unprotectBranch", me, main.clone()); assert_eq!((status, rules), (200, json!({"rules": []}))); assert_eq!(server.call("unprotectBranch", me, main).0, 400); // Labels, defined, refused a bad color, and taken away. let bug = json!({"name": " kind/bug ", "color": "#d73a4a", "description": "Broken", "exclusive": true}); let (status, labels) = server.call("defineLabel", me, with(bug)); assert_eq!(status, 200, "{labels}"); let defined = json!([{"name": "kind/bug", "color": "#d73a4a", "description": "Broken", "exclusive": true}]); assert_eq!(labels["labels"], defined); let plain = with(json!({"name": "plain", "color": "", "description": ""})); let (_, labels) = server.call("defineLabel", me, plain); assert_eq!(labels["labels"][1], json!({"name": "plain"})); let red = with(json!({"name": "x", "color": "red"})); assert_eq!(server.call("defineLabel", me, red).0, 400); let x = with(json!({"name": "x"})); assert_eq!(server.call("defineLabel", not_me, x).0, 403); let plain = with(json!({"name": "plain"})); let (status, labels) = server.call("removeLabel", me, plain.clone()); assert_eq!((status, &labels["labels"]), (200, &defined)); assert_eq!(server.call("removeLabel", me, plain).0, 400); // Triagers, named for scopes, and no longer. let triage = with(json!({"who": other.account.handle, "scopes": [{"name": "kind"}]})); let (status, triagers) = server.call("setTriager", me, triage.clone()); assert_eq!(status, 200, "{triagers}"); assert_eq!(triagers["triagers"][0]["actor"]["did"], other.account.did); assert_eq!(triagers["triagers"][0]["scopes"], json!([{"name": "kind"}])); let set = server.settings(&owner.account.did, "managed"); assert_eq!(set["triagers"], triagers["triagers"]); assert_eq!(server.call("setTriager", not_me, triage).0, 403); let none = with(json!({"who": other.account.handle, "scopes": []})); assert_eq!(server.call("setTriager", me, none).0, 400); let gone = with(json!({ "who": other.account.did })); let (status, triagers) = server.call("removeTriager", me, gone.clone()); assert_eq!((status, triagers), (200, json!({"triagers": []}))); assert_eq!(server.call("removeTriager", me, gone).0, 400); // Collaborators push through a gateway, which the owner must delegate // to first; the pages share once the repository names it. let share = with(json!({ "with": helper.account.handle })); let (status, refused) = server.call("shareRepo", me, share.clone()); assert_eq!(status, 400); let said = refused["message"].as_str().unwrap(); // Said as the pages do it, by the control there, never a command. let control = "with Delegate writes to this server, under Delegated writes in your settings"; assert!(said.ends_with(control), "{refused}"); assert!(!said.contains('`'), "{refused}"); let delegate = ["account", "delegate", &owner.account.handle, &server.did]; let (code, out) = owner.cli(&delegate, b""); assert_eq!(code, 0, "{out}"); let first = ["repo", "share", &managed, &other.account.handle, "--write"]; assert_eq!(owner.cli(&first, b"").0, 0); let (status, shared) = server.call("shareRepo", me, share.clone()); assert_eq!(status, 200, "{shared}"); let writers = shared["collaborators"].as_array().unwrap().iter(); let dids: Vec<&str> = writers.map(|a| a["did"].as_str().unwrap()).collect(); assert_eq!( dids, [other.account.did.as_str(), helper.account.did.as_str()] ); // Its page names them, in its record's order, and how many. let shown = server.repo(&owner.account.did, "managed"); let named = shown["collaborators"].as_array().unwrap().iter(); let named: Vec<&str> = named.map(|a| a["did"].as_str().unwrap()).collect(); assert_eq!(named, dids, "{shown}"); assert_eq!(shown["collaboratorCount"], 2); assert_eq!(server.call("shareRepo", not_me, share.clone()).0, 403); let (status, unshared) = server.call("unshareRepo", me, share); assert_eq!(status, 200, "{unshared}"); let left = unshared["collaborators"].as_array().unwrap(); assert_eq!( (left.len(), &left[0]["did"]), (1, &json!(other.account.did)) ); let set = server.settings(&owner.account.did, "managed"); assert_eq!(set["collaborators"][0]["did"], other.account.did); // A collaborator makes branches from the pages too, through the gateway. let (status, made) = server.call("createBranch", not_me, theirs); assert_eq!(status, 200, "{made}"); } /// A member who never logged in here reads a private repository with a /// delegation token from their own PDS, one to each query, as a site that /// signed them in elsewhere asks: the repository, its files and its /// issues, for them alone. A token spent once reads nothing more, and a /// stranger's, or one for another space, reads nothing. #[test] fn delegation_tokens_read_a_private_repository() { if !net().spaces { return; } let server = server(); let owner = person(account()); let member = person(account()); let stranger = person(account()); let url = owner.url("kept"); assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let share = ["repo", "share", &url, &member.account.handle]; assert_eq!(owner.cli(&share, b"").0, 0); let (code, out) = member.cli(&["issue", "create", &url, "Kept trouble"], b""); assert_eq!(code, 0, "{out}"); let at = format!("at://{}/dev.knotless.repo/kept", owner.account.did); let space = knotless::space::uri(&owner.account.did, "kept"); let rt = tokio::runtime::Runtime::new().unwrap(); let token = |s: &Sandbox, space: &str| { let c = ctx(s); let me = rt.block_on(knotless::session::me(&c)).unwrap(); rt.block_on(knotless::space::delegation(&c, space, &me)) .unwrap() }; let client = reqwest::blocking::Client::new(); let ask = |method: &str, query: &str, token: Option<&str>| { let mut req = client.get(format!("{}?repo={at}{query}", server.xrpc(method))); if let Some(token) = token { req = req.header(knotless::space::DELEGATION_HEADER, token); } let res = req.send().unwrap(); let cache = res.headers().get("cache-control").cloned(); let cache = cache.map(|c| c.to_str().unwrap().to_string()); ( res.status().as_u16(), cache, res.json().unwrap_or(Value::Null), ) }; // A page on another site may ask, with the token in a header of its // own. let preflight = client .request( reqwest::Method::OPTIONS, format!("{}?repo={at}", server.xrpc("getRepo")), ) .header("origin", "https://elsewhere.example") .header("access-control-request-method", "GET") .header( "access-control-request-headers", knotless::space::DELEGATION_HEADER, ) .send() .unwrap(); assert!(preflight.status().is_success(), "{}", preflight.status()); let allowed = preflight.headers().get("access-control-allow-origin"); assert_eq!(allowed.unwrap(), "*"); // And read where to ask, from this server's DID document. let doc = client .get(format!("{}/.well-known/did.json", server.base)) .header("origin", "https://elsewhere.example") .send() .unwrap(); let allowed = doc.headers().get("access-control-allow-origin"); assert_eq!(allowed.unwrap(), "*"); // No one's: as if there were none. assert_eq!(ask("getRepo", "", None).0, 404); // The member's, each query with its own, and kept by no cache. let (status, cache, repo) = ask("getRepo", "", Some(&token(&member, &space))); assert_eq!(status, 200, "{repo}"); assert_eq!(repo["private"], true); assert_eq!(cache.as_deref(), Some("private, no-store")); let (status, _, tree) = ask("getTree", "", Some(&token(&member, &space))); assert_eq!(status, 200, "{tree}"); assert!(!tree["entries"].as_array().unwrap().is_empty(), "{tree}"); // Its issues from this server's index of the space, synced with what // the token got, as no session here syncs it. let issues = ask("listItems", "&kind=issue", Some(&token(&member, &space))); assert_eq!(issues.0, 200, "{}", issues.2); assert_eq!( issues.2["items"][0]["title"], "Kept trouble", "{}", issues.2 ); // What it reads again as them from their PDS, such as the workflows // a ref has, finds the credential it got. let asked = ask("listWorkflows", "", Some(&token(&member, &space))); assert_eq!(asked.0, 200, "{}", asked.2); // The template a new issue is asked to follow, read from the mirror. let asked = ask("getTemplate", "&kind=issue", Some(&token(&member, &space))); assert_eq!(asked.0, 200, "{}", asked.2); // Spent once. let once = token(&member, &space); assert_eq!(ask("getRepo", "", Some(&once)).0, 200); assert_eq!(ask("getRepo", "", Some(&once)).0, 401); // Not a member: their own PDS signs one, which the owner's refuses. assert_eq!(ask("getRepo", "", Some(&token(&stranger, &space))).0, 404); // For another space, it reads this one as anyone. let other = knotless::space::uri(&owner.account.did, "elsewhere"); assert_eq!(ask("getRepo", "", Some(&token(&member, &other))).0, 404); // With a service token, it must be the same account's; and what is // sent as one must be one. let service = |s: &Sandbox| { let c = ctx(s); let me = rt.block_on(knotless::session::me(&c)).unwrap(); let lxm = "dev.knotless.getRepo"; rt.block_on(knotless::auth::service_token(&c, &me, &server.did, lxm)) .unwrap() }; let both = |bearer: &str, delegation: &str| { let req = client.get(format!("{}?repo={at}", server.xrpc("getRepo"))); let req = req.header("authorization", format!("Bearer {bearer}")); let req = req.header(knotless::space::DELEGATION_HEADER, delegation); req.send().unwrap().status().as_u16() }; assert_eq!(both(&service(&member), &token(&member, &space)), 200); assert_eq!(both(&service(&stranger), &token(&member, &space)), 401); assert_eq!(ask("getRepo", "", Some(&service(&member))).0, 401); } /// A private repository's settings, read by its owner alone; and a member /// forks it from the pages into a fork as private as it is. #[test] fn private_repositories_settings_and_forks() { if !net().spaces { return; } let server = server(); let owner = person(account()); let member = person(account()); 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"]); let share = ["repo", "share", &url, &member.account.handle]; assert_eq!(owner.cli(&share, b"").0, 0); let mine = log_in(&server, &owner.account); let me = Some(mine.as_str()); let at = format!("at://{}/dev.knotless.repo/hidden", owner.account.did); // Its settings, read as its owner, with nothing of indexing in them: // every private repository read here is indexed here. let settings = |cookie: Option<&str>| { let asked = format!("{}?repo={at}", server.xrpc("getRepoSettings")); let (status, _, answer) = browse("GET", &asked, cookie, None); (status, answer) }; let (status, set) = settings(me); assert_eq!(status, 200, "{set}"); assert_eq!(set.get("indexed"), None); let counted = format!("{}?repo={at}", server.xrpc("getRepoStorage")); let (status, _, storage) = browse("GET", &counted, me, None); assert_eq!((status, &storage["packs"]), (200, &json!(1)), "{storage}"); assert_eq!(browse("GET", &counted, None, None).0, 404); assert_eq!(settings(None).0, 404); // Who reads it but does not push, to its owner, as its record says: // the member it was shared with, for reading, and one shared so from // the pages, at once. A member is shown none. let read = |set: &Value| -> Vec { let readers = set["readers"].as_array().unwrap().iter(); readers .map(|r| r["did"].as_str().unwrap().to_string()) .collect() }; assert_eq!( read(&set), std::slice::from_ref(&member.account.did), "{set}" ); let reader = person(account()); let sharing = json!({"repo": at, "with": reader.account.handle, "write": false}); let (status, shared) = server.call("shareRepo", me, sharing); assert_eq!(status, 200, "{shared}"); let mut both = read(&settings(me).1); both.sort(); let mut want = [member.account.did.clone(), reader.account.did.clone()]; want.sort(); assert_eq!(both, want); let theirs = log_in(&server, &member.account); let (status, set) = settings(Some(theirs.as_str())); assert_eq!((status, &set["readers"]), (200, &json!([])), "{set}"); // Changed from the pages, they say so at once: read from this server's // index of its space, which the change is put in as it is made. let described = json!({"repo": at, "description": "Kept quiet"}); assert_eq!(server.call("updateRepo", me, described).0, 200); assert_eq!(settings(me).1["description"], "Kept quiet"); // Searched for, it is found by its owner, first and marked private, as // the search palette lists it, and by no one else. let search = format!("{}?q=quiet", server.xrpc("listRepos")); let (status, _, found) = browse("GET", &search, me, None); assert_eq!(status, 200, "{found}"); assert_eq!(found["repos"][0]["name"], "hidden", "{found}"); assert_eq!(found["repos"][0]["private"], true); let (_, _, found) = browse("GET", &search, None, None); let mut repos = found["repos"].as_array().unwrap().iter(); assert!(repos.all(|r| r["name"] != "hidden"), "{found}"); // An issue opened from the pages is read back at once, as the page it // goes to reads it: from this server's index of the space, before its // sync brings it. let asked = json!({"repo": at, "title": "Opened here"}); let (status, opened) = server.call("createIssue", me, asked); assert_eq!(status, 200, "{opened}"); let issue = opened["uri"].as_str().unwrap(); let thread = format!("{}?repo={at}&uri={issue}", server.xrpc("getThread")); let (status, _, shown) = browse("GET", &thread, me, None); assert_eq!(status, 200, "{shown}"); assert_eq!(shown["item"]["title"], "Opened here"); // A member forks it from the pages, and the fork is private too. let theirs = log_in(&server, &member.account); let them = Some(theirs.as_str()); let (status, forked) = server.call("forkRepo", them, json!({ "repo": at })); assert_eq!(status, 200, "{forked}"); let fork = forked["uri"].as_str().unwrap(); let asked = format!("{}?repo={fork}", server.xrpc("getRepo")); let (status, _, shown) = browse("GET", &asked, them, None); assert_eq!(status, 200, "{shown}"); assert_eq!(shown["private"], true); assert_eq!(browse("GET", &asked, None, None).0, 404); } impl Server { /// `getAccountData`'s status and answer, as the browser holding `cookie`. fn account_data(&self, cookie: Option<&str>) -> (u16, Value) { let (status, _, answer) = browse("GET", &self.xrpc("getAccountData"), cookie, None); (status, answer) } /// Asks `deleteAccountData` for `did`'s data, `limit` at a time, until /// it is done: the answers, and the cookies the last one set. fn delete_data(&self, cookie: &str, did: &str, limit: u64) -> (Vec, Vec) { let mut answers = Vec::new(); loop { let asked = json!({"did": did, "limit": limit}); let (status, set, answer) = browse( "POST", &self.xrpc("deleteAccountData"), Some(cookie), Some(asked), ); assert_eq!(status, 200, "{answer}"); answers.push(answer.clone()); if answer["done"] == true { return (answers, set); } assert!(answers.len() < 100, "{answers:?}"); } } } /// An account deletes its knotless data from the pages: told first what /// there is, its repositories others push to among it, then deleted a part /// a call, each call carrying on where the last stopped, until none is /// left; then the appview forgets it and logs it out, keeping the /// operator's takedowns. The command line does the same with its own /// session, and asks the appview only to forget it, which it does once /// nothing is left. #[test] fn an_account_deletes_its_knotless_data() { let server = server(); let owner = person(account()); let mut other = person(account()); let did = owner.account.did.clone(); let theirs = other.url("theirs"); assert_eq!(other.cli(&["repo", "create", &theirs], b"").0, 0); for name in ["one", "two"] { assert_eq!(owner.cli(&["repo", "create", &owner.url(name)], b"").0, 0); } // Each lets the other push, as sharing for writing does once delegated. let push = |by: &Sandbox, name: &str, to: &Sandbox| { let mut repo = by.record("dev.knotless.repo", name); repo["collaborators"] = json!([{ "did": to.account.did }]); by.put_record("dev.knotless.repo", name, repo); }; push(&owner, "two", &other); push(&other, "theirs", &owner); assert_eq!(owner.cli(&["issue", "create", &theirs, "Hello"], b"").0, 0); assert_eq!(owner.cli(&["repo", "star", &theirs], b"").0, 0); let (rkey, _) = owner.records("dev.knotless.issue").remove(0); let issue = format!("at://{did}/dev.knotless.issue/{rkey}"); let index = server.app.index.clone().unwrap(); let subject = json!({"$type": "com.atproto.repo.strongRef", "uri": issue}); index .take_down(&server.did, &issue, &subject, true) .unwrap(); // Only the account, logged in to the pages, is told what it holds. assert_eq!(server.account_data(None).0, 401); let cookie = log_in(&server, &owner.account); let me = Some(cookie.as_str()); let (status, held) = server.account_data(me); assert_eq!(status, 200, "{held}"); assert_eq!(held["did"], did.as_str()); assert_eq!(held["spaces"], 0); let counted = |collection: &str| { let all = held["collections"].as_array().unwrap().iter(); let found = all.clone().find(|c| c["collection"] == collection); found.map_or(json!(0), |c| c["count"].clone()) }; assert_eq!( (counted("dev.knotless.repo"), counted("dev.knotless.issue")), (json!(2), json!(1)), "{held}" ); assert_eq!(counted("dev.knotless.star"), 1, "{held}"); let repos = json!([ {"name": "one", "private": false, "collaborators": 0}, {"name": "two", "private": false, "collaborators": 1}, ]); assert_eq!(held["repos"], repos); // Only its own, and only from the pages. let named = json!({"did": other.account.did}); assert_eq!(server.call("deleteAccountData", me, named).0, 403); let mine = json!({ "did": did }); assert_eq!(server.call("deleteAccountData", None, mine).0, 401); // A record at a time, a repository whole: the first call deletes one. let (answers, set) = server.delete_data(&cookie, &did, 1); assert_eq!(answers[0]["done"], false, "{answers:?}"); assert!(answers.len() > 2, "{answers:?}"); let deleted: u64 = answers.iter().map(|a| a["deleted"].as_u64().unwrap()).sum(); assert!(deleted >= 4, "{answers:?}"); // Logged out, the browser holding no other account. assert!(set[0].starts_with("knotless_session=;"), "{set:?}"); assert_eq!(server.account_data(me).0, 401); let owners = ctx(&owner); let rt = &server._rt; assert!( !rt.block_on(knotless::account::holds_any(&owners, &did)) .unwrap() ); assert!(owner.record("dev.knotless.repo", "one").is_null()); // Forgotten here, but for the takedown. let asked = format!( "{}?repo=at://{did}/dev.knotless.repo/one", server.xrpc("getRepo") ); wait_until(|| browse("GET", &asked, None, None).0 == 404); assert!(index.taken_down(&issue).unwrap().is_some()); // Theirs stands, with what its owner said of it. assert_eq!(server.repo(&other.account.did, "theirs")["name"], "theirs"); // The command line: asked to be forgotten while records remain, the // appview refuses. A repository it shares with no one, and a follow, // left after its repositories go. assert_eq!( other.cli(&["repo", "create", &other.url("plain")], b"").0, 0 ); let follow = ["account", "follow", &owner.account.handle]; assert_eq!(other.cli(&follow, b"").0, 0); other.set("KNOTLESS_APPVIEW", &server.base); let early = rt.block_on(knotless::account::forget(&ctx(&other))); let early = format!("{:#}", early.unwrap_err()); assert!(early.contains("RecordsRemain"), "{early}"); let handle = other.account.handle.clone(); let (code, said) = other.cli(&["account", "delete-data", &handle, "--now"], b""); assert_ne!(code, 0, "{said}"); // It says what there is, and deletes nothing until the handle is typed. let (code, said) = other.cli(&["account", "delete-data", &handle], b"someone.else\n"); assert_ne!(code, 0, "{said}"); assert!(said.contains("repository theirs, shared with 1"), "{said}"); assert!(said.contains("repository plain\n"), "{said}"); assert!(!other.record("dev.knotless.repo", "theirs").is_null()); let (code, said) = other.cli(&["account", "delete-data", &handle, "--yes"], b""); assert_eq!(code, 0, "{said}"); assert!(said.ends_with("and logged out"), "{said}"); assert!(other.record("dev.knotless.repo", "theirs").is_null()); let theirs = format!( "{}?repo=at://{}/dev.knotless.repo/theirs", server.xrpc("getRepo"), other.account.did ); wait_until(|| browse("GET", &theirs, None, None).0 == 404); } /// Private repositories go with their spaces, a space a part, those shared /// named first with how many read them. #[test] fn an_account_deletes_its_private_repositories() { if !net().spaces { return; } let server = server(); let owner = person(account()); let member = person(account()); let runner = person(account()); for name in ["hidden", "secret"] { let url = owner.url(name); assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0); } // One who pushes too, through the server as gateway, and a runner, // which reads it but is not counted. let delegate = ["account", "delegate", &owner.account.handle, &server.did]; assert_eq!(owner.cli(&delegate, b"").0, 0); let secret = owner.url("secret"); let share = ["repo", "share", &secret, &member.account.handle, "--write"]; assert_eq!(owner.cli(&share, b"").0, 0); let runs = ["runner", "add", &secret, &runner.account.handle]; assert_eq!(owner.cli(&runs, b"").0, 0); let cookie = log_in(&server, &owner.account); let (status, held) = server.account_data(Some(&cookie)); assert_eq!(status, 200, "{held}"); assert_eq!(held["spaces"], 2, "{held}"); let repos = json!([ {"name": "hidden", "private": true, "collaborators": 0, "members": 0}, {"name": "secret", "private": true, "collaborators": 1, "members": 1}, ]); assert_eq!(held["repos"], repos); // Its runner reads it, so its settings name it among those who do; the // member pushes, so is not. let at = format!("at://{}/dev.knotless.repo/secret", owner.account.did); let asked = format!("{}?repo={at}", server.xrpc("getRepoSettings")); let (status, _, set) = browse("GET", &asked, Some(&cookie), None); assert_eq!(status, 200, "{set}"); let readers = set["readers"].as_array().unwrap(); let dids: Vec<&Value> = readers.iter().map(|r| &r["did"]).collect(); assert_eq!(dids, [&json!(runner.account.did)], "{set}"); let did = owner.account.did.clone(); let (answers, _) = server.delete_data(&cookie, &did, 1); assert_eq!(answers[0], json!({"deleted": 1, "done": false})); let owners = ctx(&owner); let spaces = server._rt.block_on(async { let me = knotless::session::me(&owners).await?; knotless::remote::Remote::own_spaces(&owners, &me).await }); assert_eq!(spaces.unwrap(), Some(Vec::new())); } /// An organization run from the pages, as `knotless org` runs one: begun /// with its first invitation, through the server it delegated to from /// the command line; heard of in the notifications of those it invited, /// who join, show or hide it, leave and decline; changed by an admin; and /// ended. #[test] fn an_organization_from_the_pages() { let server = server(); let [org, member, third] = [account(), account(), account()]; let owner = person(org.clone()); let delegate = ["account", "delegate", &org.handle, &server.did]; assert_eq!(owner.cli(&delegate, b"").0, 0); let (o, m) = (log_in(&server, &org), log_in(&server, &member)); let (o, m) = (Some(o.as_str()), Some(m.as_str())); let viewer = |cookie| { let url = server.xrpc("getViewer"); browse("GET", &url, cookie, None).2 }; let roster = |cookie| { let url = format!("{}?actor={}", server.xrpc("getOrg"), org.did); let (status, _, answer) = browse("GET", &url, cookie, None); assert_eq!(status, 200, "{answer}"); answer }; let standing = |cookie, did: &str| { let all = roster(cookie)["members"].as_array().unwrap().clone(); let found = all.into_iter().find(|v| v["actor"]["did"] == did); found.map(|v| (v["role"].clone(), v["standing"].clone())) }; let invite = |cookie, who: &str, role: &str| { let input = json!({"org": org.did, "member": who, "role": role}); server.call("inviteToOrg", cookie, input) }; let with = |visibility: &str| json!({"org": org.did, "visibility": visibility}); let only = json!({ "org": org.did }); // The invitations `cookie`'s account has to answer, by who and the // role, as its notifications have them. let invitations = |cookie| { let url = server.xrpc("listNotifications"); let (status, _, answer) = browse("GET", &url, cookie, None); assert_eq!(status, 200, "{answer}"); let all = answer["notifications"].as_array().unwrap().clone(); let invites = all.into_iter().filter(|n| n["reason"] == "invite"); invites .map(|n| (n["author"]["did"].clone(), n["role"].clone())) .collect::>() }; // Logged out, nothing; someone else cannot begin it for them. assert_eq!(invite(None, &member.handle, "member").0, 401); assert_eq!(invite(m, &third.handle, "member").0, 400); assert_eq!(viewer(o).get("organization"), None); assert_eq!(invite(o, &member.handle, "admin"), (200, json!({}))); assert_eq!(viewer(o)["organization"], true); let invited = (json!("admin"), json!("invited")); assert_eq!(standing(m, &member.did), Some(invited)); assert_eq!(standing(None, &member.did), None); // It waits in their notifications until they answer it. let waiting = vec![(json!(org.did), json!("admin"))]; assert_eq!(invitations(m), waiting); // Joined in public, anyone sees it. assert_eq!(server.call("setOrgMembership", m, with("public")).0, 200); assert_eq!(invitations(m), vec![]); let public = (json!("admin"), json!("public")); assert_eq!(standing(None, &member.did), Some(public)); if net().spaces { assert_eq!(server.call("setOrgMembership", m, with("hidden")).0, 200); assert_eq!(standing(None, &member.did), None); let hidden = (json!("admin"), json!("hidden")); assert_eq!(standing(o, &member.did), Some(hidden)); } // An admin invites and withdraws; the organization changes a role. assert_eq!(invite(m, &third.handle, "member").0, 200); let third_in = (json!("member"), json!("invited")); assert_eq!(standing(o, &third.did), Some(third_in)); let gone = json!({"org": org.did, "member": third.did}); assert_eq!(server.call("removeFromOrg", m, gone).0, 200); assert_eq!(standing(o, &third.did), None); assert_eq!(invite(o, &member.did, "member").0, 200); assert_eq!(standing(o, &member.did).unwrap().0, "member"); // Left, and invited again, declined. let (status, left) = server.call("endOrgMembership", m, only.clone()); assert_eq!((status, left), (200, json!({}))); assert_eq!(standing(o, &member.did), None); assert_eq!(invite(o, &member.handle, "member").0, 200); assert_eq!(invitations(m), vec![(json!(org.did), json!("member"))]); assert_eq!(server.call("endOrgMembership", m, only).0, 200); assert_eq!(standing(o, &member.did), None); assert_eq!(invitations(m), vec![]); // Ended, it is a plain account again. assert_eq!(server.call("deleteOrg", o, json!({})), (200, json!({}))); assert_eq!(viewer(o).get("organization"), None); assert_eq!(roster(None)["organization"], false); } /// What a request to `server` as the site at `host` got: its status, where /// it goes, the cookie it sets, how it may be cached, and its body. struct Visit { status: u16, location: String, cookie: Option, cache: String, body: String, } fn visit(server: &Server, host: &str, path: &str, cookie: Option<&str>) -> Visit { let client = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); let mut req = client.get(format!("{}{path}", server.base)); req = req.header("x-forwarded-host", host); if let Some(cookie) = cookie { req = req.header("cookie", cookie); } let res = req.send().unwrap(); let header = |name: &str| { let value = res.headers().get(name); value.map(|v| v.to_str().unwrap().to_string()) }; let location = header("location").unwrap_or_default(); let cookie = header("set-cookie").map(|c| c.split(';').next().unwrap().to_string()); let cache = header("cache-control").unwrap_or_default(); Visit { status: res.status().as_u16(), location, cookie, cache, body: res.text().unwrap(), } } /// A private repository's site, deployed into its space and read by its /// members alone: sent from its host to the pages for a ticket, which /// they are given once logged in and proven a member, and let in by the /// pass the host trades it for. A stranger is given none; a member /// removed, or logged out, reads it no more. #[test] fn private_sites_are_read_by_members() { if !net().spaces { return; } let server = server(); let mut owner = person(account()); // The appview hears of a deploy, as nothing in a space reaches the // firehose. owner.set("KNOTLESS_APPVIEW", &server.base); let member = person(account()); let stranger = person(account()); let url = owner.url("hush"); assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let share = |undo: bool| { let mut share = vec!["repo", "share", &url, &member.account.handle]; if undo { share.push("--undo"); } owner.cli(&share, b"").0 }; assert_eq!(share(false), 0); let dist = owner.path("dist"); std::fs::create_dir_all(dist.join("docs")).unwrap(); std::fs::write(dist.join("index.html"), b"

hushed

").unwrap(); std::fs::write(dist.join("docs/index.html"), b"docs").unwrap(); let dir = dist.to_string_lossy().into_owned(); let (code, out) = owner.cli(&["repo", "site", &url, &dir], b""); assert_eq!(code, 0, "{out}"); // At a host of its own, named by its key and its owner's plc id. let address = out.lines().last().unwrap(); let host = address .strip_prefix("https://") .and_then(|a| a.strip_suffix("/")) .unwrap() .to_string(); let plc = owner.account.did.strip_prefix("did:plc:").unwrap(); assert!( host.ends_with(&format!("-{plc}.private.knotless.page")), "{host}" ); // Its record is in the space, not among the owner's public ones. assert!(owner.records("dev.knotless.site").is_empty()); let (_, live) = owner.cli(&["repo", "site", &url], b""); assert!(live.starts_with(&format!("{address}\t2 files\t")), "{live}"); // No pass: sent to the pages for a ticket, back to the page asked. let first = visit(&server, &host, "/docs/?x=1", None); assert_eq!(first.status, 302); assert_eq!( first.location, format!( "{}/oauth/site?host={host}&to=%2Fdocs%2F%3Fx%3D1", server.base ) ); assert_eq!(first.cache, "private, no-store"); let tickets = first.location.strip_prefix(&server.base).unwrap(); // Logged in as no one, the pages ask for a login, and come back. let asked = visit(&server, "localhost", tickets, None); assert_eq!(asked.status, 302); assert!( asked.location.starts_with("/?login=%2Foauth%2Fsite%3F"), "{}", asked.location ); // A member logged in is given a ticket for that host alone. let theirs = log_in(&server, &member.account); // Their page of its repository shows it. let repo = format!("at://{}/dev.knotless.repo/hush", owner.account.did); let page = format!("{}?repo={repo}", server.xrpc("getRepo")); wait_until(|| { let (status, _, answer) = browse("GET", &page, Some(&theirs), None); status == 200 && answer["site"]["url"] == address }); let given = visit(&server, "localhost", tickets, Some(&theirs)); assert_eq!(given.status, 302); let enter = format!("http://{host}/.knotless/enter?ticket="); assert!(given.location.starts_with(&enter), "{}", given.location); let enter = given .location .strip_prefix(&format!("http://{host}")) .unwrap(); assert!(enter.ends_with("&to=%2Fdocs%2F%3Fx%3D1"), "{enter}"); // Not taken on another site's host. let other_key = if host.starts_with('2') { "3" } else { "2" }; let elsewhere = format!("{other_key}{}", &host[1..]); let wrong = visit(&server, &elsewhere, enter, None); assert!( wrong.location.contains("/oauth/site?"), "{}", wrong.location ); assert_eq!(wrong.cookie, None); // Traded on its own for a pass, once. let entered = visit(&server, &host, enter, None); assert_eq!( (entered.status, entered.location.as_str()), (302, "/docs/?x=1") ); let pass = entered.cookie.unwrap(); assert!(pass.starts_with("knotless_site="), "{pass}"); let again = visit(&server, &host, enter, None); assert!( again.location.contains("/oauth/site?"), "{}", again.location ); assert_eq!(again.cookie, None); // With it, the site, for them alone. let page = visit(&server, &host, "/", Some(&pass)); assert_eq!((page.status, page.body.as_str()), (200, "

hushed

")); assert_eq!(page.cache, "private, no-store"); assert_eq!(visit(&server, &host, "/docs/", Some(&pass)).body, "docs"); let moved = visit(&server, &host, "/docs", Some(&pass)); assert_eq!((moved.status, moved.location.as_str()), (302, "docs/")); assert_eq!(visit(&server, &host, "/gone.js", Some(&pass)).status, 404); assert_eq!(visit(&server, &host, "/%ff", Some(&pass)).status, 404); // The pass is the host's alone. let other = visit(&server, &elsewhere, "/", Some(&pass)); assert!( other.location.contains("/oauth/site?"), "{}", other.location ); // A stranger logged in is given no ticket. let strangers = log_in(&server, &stranger.account); let refused = visit(&server, "localhost", tickets, Some(&strangers)); assert_eq!(refused.status, 404); // Nor is a host that names no private site's. let odd = "/oauth/site?host=nobody.knotless.page&to=%2F"; assert_eq!(visit(&server, "localhost", odd, Some(&theirs)).status, 404); // Removed, the member reads it no more once membership is asked // again; shared again, they do. assert_eq!(share(true), 0); server .app .reprove .store(0, std::sync::atomic::Ordering::Relaxed); assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404); assert_eq!( visit(&server, "localhost", tickets, Some(&theirs)).status, 404 ); assert_eq!(share(false), 0); assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200); // Redeployed, the new site is read once the space is synced, as // following it hears of the write. let space = knotless::space::uri(&owner.account.did, "hush"); let index = server.app.spaces.clone().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap(); let synced = || { let c = ctx(&owner); rt.block_on(knotless_serve::spaces::sync(&c, &index, &space)) .unwrap(); }; std::fs::write(dist.join("index.html"), b"

again

").unwrap(); assert_eq!(owner.cli(&["repo", "site", &url, &dir], b"").0, 0); synced(); assert_eq!( visit(&server, &host, "/", Some(&pass)).body, "

again

" ); // Logged out, their pass no longer holds. let logout = format!("{}/oauth/logout", server.base); assert_eq!(browse("POST", &logout, Some(&theirs), None).0, 200); let out = visit(&server, &host, "/", Some(&pass)); assert!(out.location.contains("/oauth/site?"), "{}", out.location); // Taken down by its owner, it is gone. let back = log_in(&server, &member.account); let given = visit(&server, "localhost", tickets, Some(&back)); let enter = given .location .strip_prefix(&format!("http://{host}")) .unwrap(); let pass = visit(&server, &host, enter, None).cookie.unwrap(); assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200); assert_eq!(owner.cli(&["repo", "site", &url, "--down"], b"").0, 0); synced(); assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404); }