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