johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<App>,
_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<Value>,
) -> (u16, Vec<String>, 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<String> {
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::<String>::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::<Vec<_>>()
};
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<String> {
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<Value>, Vec<String>) {
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::<Vec<_>>()
};
// 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<String>,
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"<h1>hushed</h1>").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, "<h1>hushed</h1>"));
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"<h1>again</h1>").unwrap();
assert_eq!(owner.cli(&["repo", "site", &url, &dir], b"").0, 0);
synced();
assert_eq!(
visit(&server, &host, "/", Some(&pass)).body,
"<h1>again</h1>"
);
// 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);
}