//! The MCP server against a reference PDS, driven by an MCP client. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::*; use rmcp::ServiceExt; use rmcp::model::CallToolRequestParams; use serde_json::{Value, json}; struct Client { rt: tokio::runtime::Runtime, peer: rmcp::service::RunningService, } impl Client { fn new(s: &Sandbox) -> Client { let rt = tokio::runtime::Runtime::new().unwrap(); let ctx = ctx(s); let peer = rt.block_on(async { let (server_io, client_io) = tokio::io::duplex(1 << 20); tokio::spawn(async move { let running = knotless_mcp::Server::new(ctx) .serve(server_io) .await .unwrap(); let _ = running.waiting().await; }); ().serve(client_io).await.unwrap() }); Client { rt, peer } } /// The tool's text, and whether it was an error. fn call(&self, tool: &str, args: Value) -> (String, bool) { let params = CallToolRequestParams::new(tool.to_string()) .with_arguments(args.as_object().unwrap().clone()); let res = self.rt.block_on(self.peer.call_tool(params)).unwrap(); let text = res.content[0].as_text().unwrap().text.clone(); (text, res.is_error == Some(true)) } fn ok(&self, tool: &str, args: Value) -> String { let (text, err) = self.call(tool, args); assert!(!err, "{tool}: {text}"); text } fn json(&self, tool: &str, args: Value) -> Value { serde_json::from_str(&self.ok(tool, args)).unwrap() } } /// Logged in as its account, as `knotless login` leaves a machine. fn logged_in() -> Sandbox { let mut s = Sandbox::new(); s.set("KNOTLESS_PASSWORD", ""); 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 } #[test] fn an_agent_works_on_a_repository() { let s = logged_in(); let c = Client::new(&s); let tools = c.rt.block_on(c.peer.list_all_tools()).unwrap(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); for tool in ["read_file", "commit", "search", "create_repo", "share"] { assert!(names.contains(&tool), "{names:?}"); } let created = c.json( "create_repo", json!({"name": "agentic", "description": "made by a tool"}), ); let repo = created["repo"].as_str().unwrap().to_string(); assert_eq!(repo, format!("{}/agentic", s.account.handle)); let (text, err) = c.call("create_repo", json!({"name": "agentic"})); assert!(err && text.contains("already exists"), "{text}"); let first = c.json( "commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [ {"path": "README.md", "content": "# agentic\n\nhello world\n"}, {"path": "src/main.rs", "content": "fn main() {}\n"}, {"path": "bin/run", "content": "#!/bin/sh\n", "executable": true}, {"path": "logo.bin", "base64": "AAEC/w=="}, ]}), ); assert_eq!(first["changed"], true); let second = c.json( "commit", json!({"repo": format!("knotless://{repo}"), "branch": "main", "message": "tidy", "changes": [ {"path": "bin/run", "delete": true}, {"path": "README.md", "content": "# agentic\n\nhello there\n"}, ], "base": first["sha"]}), ); let (text, err) = c.call( "commit", json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "content": "x", "delete": true}]}), ); assert!(err && text.contains("exactly one"), "{text}"); let (text, err) = c.call( "commit", json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "base64": "!!"}]}), ); assert!(err, "{text}"); let refs = c.json("refs", json!({"repo": repo})); assert_eq!(refs["refs"][0]["sha"], second["sha"]); assert_eq!(refs["private"], false); let tree = c.json("list_tree", json!({"repo": repo})); let names: Vec<&str> = tree .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert_eq!(names, ["README.md", "logo.bin", "src"]); let sub = c.json( "list_tree", json!({"repo": repo, "path": "src", "rev": first["sha"]}), ); assert_eq!(sub[0]["name"], "main.rs"); assert_eq!( c.ok("read_file", json!({"repo": repo, "path": "README.md"})), "# agentic\n\nhello there\n" ); assert_eq!( c.json("read_file", json!({"repo": repo, "path": "logo.bin"}))["base64"], "AAEC/w==" ); let hits = c.json("search", json!({"repo": repo, "pattern": "HELLO"})); assert_eq!(hits[0]["path"], "README.md"); assert_eq!(hits[0]["line"], 3); let log = c.json("log", json!({"repo": repo, "limit": 5})); assert_eq!(log.as_array().unwrap().len(), 2); assert_eq!(log[0]["message"], "tidy"); let touched = c.json("log", json!({"repo": repo, "path": "src"})); assert_eq!(touched.as_array().unwrap().len(), 1); let diff = c.ok( "diff", json!({"repo": repo, "base": first["sha"], "head": "main"}), ); assert!( diff.contains("-hello world") && diff.contains("+hello there"), "{diff}" ); // A new branch starts from the default branch, or from `base`, and // its commits are the committer's. let feature = c.json("commit", json!({"repo": repo, "branch": "feature", "message": "f", "changes": [{"path": "f", "content": "f\n"}]})); assert_eq!(feature["parent"], second["sha"]); let fix = c.json("commit", json!({"repo": repo, "branch": "fix", "message": "x", "changes": [{"path": "x", "content": "x\n"}], "base": first["sha"]})); assert_eq!(fix["parent"], first["sha"]); let log = c.json("log", json!({"repo": repo, "rev": "feature", "limit": 1})); assert_eq!(log[0]["author"]["name"], s.account.handle.as_str()); // Branches are made and deleted without a commit. let develop = c.json("create_branch", json!({"repo": repo, "branch": "develop"})); assert_eq!(develop["sha"], second["sha"]); let old = c.json( "create_branch", json!({"repo": repo, "branch": "old", "from": first["sha"]}), ); assert_eq!(old["sha"], first["sha"]); c.json("delete_branch", json!({"repo": repo, "branch": "old"})); c.json( "create_branch", json!({"repo": repo, "branch": "gone", "from": "main"}), ); c.json("delete_branch", json!({"repo": repo, "branch": "gone"})); // A deleted branch can be made again. c.json("create_branch", json!({"repo": repo, "branch": "gone"})); c.json("delete_branch", json!({"repo": repo, "branch": "gone"})); let refs_now = c.json("refs", json!({"repo": repo}))["refs"].to_string(); assert!( refs_now.contains("develop") && !refs_now.contains("refs/heads/old"), "{refs_now}" ); for (tool, args, why) in [ ( "create_branch", json!({"repo": repo, "branch": "develop"}), "already has", ), ( "create_branch", json!({"repo": repo, "branch": "y", "from": "nope"}), "no commit or branch", ), ( "delete_branch", json!({"repo": repo, "branch": "old"}), "has no branch", ), ( "delete_branch", json!({"repo": repo, "branch": "main"}), "default branch", ), ] { let (text, err) = c.call(tool, args); assert!(err && text.contains(why), "{tool}: {text}"); } let repos = c.json("list_repos", json!({"owner": s.account.handle})); assert_eq!(repos[0]["name"], "agentic"); assert_eq!(repos[0]["description"], "made by a tool"); let members = c.json("members", json!({"repo": repo})); assert_eq!(members[0]["access"], "owner"); let protect = |extra: Value| { let mut args = json!({"repo": repo, "branch": "main"}); args.as_object_mut() .unwrap() .extend(extra.as_object().unwrap().clone()); c.json("protect_branch", args) }; let rule = |history| json!([{"branch": "main", "history": history}]); assert_eq!(protect(json!({})), rule("linear")); assert_eq!(protect(json!({"merges": true})), rule("merges")); assert_eq!(protect(json!({"remove": true})), json!([])); // Renamed, it answers to both names. let refs = c.json("refs", json!({"repo": repo})); let renamed = c.json("rename_repo", json!({"repo": repo, "name": "agentic2"})); let moved = format!("knotless://{}/agentic2", s.account.handle); assert_eq!(renamed["url"], moved); assert_eq!(c.json("refs", json!({"repo": repo}))["refs"], refs["refs"]); assert_eq!(c.json("refs", json!({"repo": moved}))["refs"], refs["refs"]); // Errors come back as tool errors, for the agent to read. for (tool, args) in [ ("read_file", json!({"repo": repo, "path": "nope"})), ( "read_file", json!({"repo": repo, "path": "README.md", "rev": "--all"}), ), ("list_tree", json!({"repo": "not a repo"})), ( "list_tree", json!({"repo": format!("{}/missing", s.account.handle)}), ), ( "refs", json!({"repo": format!("{}/missing", s.account.handle)}), ), ("search", json!({"repo": repo, "pattern": ""})), ("log", json!({"repo": repo, "rev": "-x"})), ("diff", json!({"repo": repo, "base": "-x", "head": "main"})), ("list_repos", json!({"owner": "no-such-handle.test"})), ( "members", json!({"repo": format!("{}/missing", s.account.handle)}), ), ("share", json!({"repo": repo, "with": s.account.handle})), ( "protect_branch", json!({"repo": repo, "branch": "main", "remove": true}), ), ( "protect_branch", json!({"repo": format!("{}/missing", s.account.handle), "branch": "main"}), ), ("rename_repo", json!({"repo": "not a repo", "name": "x"})), // With no appview, there is no gateway to assume. ("delegate", json!({})), ] { let (text, err) = c.call(tool, args.clone()); assert!(err, "{tool} {args}: {text}"); } } #[test] fn sharing_from_an_agent() { if !net().spaces { eprintln!("skipped: the PDS is not on the spaces alpha"); return; } let s = logged_in(); let c = Client::new(&s); let created = c.json("create_repo", json!({"name": "secret", "private": true})); let repo = created["repo"].as_str().unwrap().to_string(); c.json("commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "a"}]})); assert_eq!(c.json("refs", json!({"repo": repo}))["private"], true); // Its owner sees it listed, once, under the name it has now. let mine = |c: &Client| -> Vec { c.json("list_repos", json!({"owner": s.account.handle})) .as_array() .unwrap() .iter() .filter(|r| r["private"] == true) .cloned() .collect() }; assert_eq!(mine(&c)[0]["name"], "secret"); c.json("rename_repo", json!({"repo": repo, "name": "hidden"})); let listed = mine(&c); assert_eq!(listed.len(), 1, "{listed:?}"); assert_eq!(listed[0]["name"], "hidden"); c.json( "rename_repo", json!({"repo": format!("{}/hidden", s.account.handle), "name": "secret"}), ); let reader = logged_in(); let other = reader.account.clone(); let members = c.json("share", json!({"repo": repo, "with": other.handle})); assert_eq!( members[1], json!({"did": other.did, "handle": other.handle, "access": "read"}) ); // A reader may not list the others. let (text, err) = Client::new(&reader).call("members", json!({"repo": repo})); assert!(err && text.contains("owner can list"), "{text}"); let members = c.json( "share", json!({"repo": repo, "with": other.handle, "revoke": true}), ); assert_eq!(members.as_array().unwrap().len(), 1); } #[test] fn nobody_logged_in() { let mut s = Sandbox::new(); s.set("KNOTLESS_PASSWORD", ""); let c = Client::new(&s); let (text, err) = c.call("create_repo", json!({"name": "x"})); assert!(err && text.contains("not logged in"), "{text}"); // An identifier and password in the environment act as that account. let mut s = Sandbox::new(); let handle = s.account.handle.clone(); s.set("KNOTLESS_IDENTIFIER", &handle); let c = Client::new(&s); let created = c.json("create_repo", json!({"name": "from-env"})); assert_eq!(created["repo"], format!("{handle}/from-env")); } #[test] fn the_binary_speaks_mcp_on_stdio() { use std::io::{BufRead, Write}; let s = Sandbox::new(); let mut child = s .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); let mut stdin = child.stdin.take().unwrap(); writeln!( stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}}) ) .unwrap(); let mut line = String::new(); std::io::BufReader::new(child.stdout.take().unwrap()) .read_line(&mut line) .unwrap(); let res: Value = serde_json::from_str(&line).unwrap(); assert_eq!(res["result"]["serverInfo"]["name"], "knotless"); drop(stdin); assert!(child.wait().unwrap().success()); // A client that leaves before it says hello is an error. let out = s .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path()) .stdin(std::process::Stdio::null()) .output() .unwrap(); assert!(!out.status.success()); } /// An appview following the PDS, for listing public repositories' pulls /// and issues. /// It takes reports too, and its admin password is `hunter2`. /// Its OAuth callback is on an https proxy in front of it, which the /// returned `TlsProxy` keeps up. fn appview(rt: &tokio::runtime::Runtime) -> (String, Sandbox, TlsProxy) { let mut s = Sandbox::new(); s.set("KNOTLESS_ADMIN_PASSWORD", "hunter2"); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let base = format!("http://localhost:{}", listener.local_addr().unwrap().port()); let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap(); let proxy = tls_proxy(listener.local_addr().unwrap().port()); let mut gateway = knotless_serve::gateway::Gateway::new(&base).unwrap(); gateway.oauth_url = proxy.url.clone(); let app = knotless_serve::App::build( ctx(&s), std::time::Duration::ZERO, Some(gateway), Some(index), false, ); let router = knotless_serve::router(app.clone()); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); rt.spawn(knotless_serve::firehose::watch( app, net().pds.replace("http://", "ws://"), )); (base, s, proxy) } #[test] fn agents_fork_review_and_merge() { let rt = tokio::runtime::Runtime::new().unwrap(); let (base, _appview, _proxy) = appview(&rt); let mut owner = logged_in(); owner.set("KNOTLESS_APPVIEW", &base); // Delegating is approved in the browser. let browser = browser(&owner.account, ""); owner.set("KNOTLESS_BROWSER", &browser); // The appview is a gateway too, at a did:web served over http. owner.set("KNOTLESS_DID_WEB_SCHEME", "http"); let mut contrib = logged_in(); contrib.set("KNOTLESS_APPVIEW", &base); contrib.set("KNOTLESS_DID_WEB_SCHEME", "http"); let (o, c) = (Client::new(&owner), Client::new(&contrib)); let repo = c_repo(&o.json("create_repo", json!({"name": "reviewed"}))); o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]})); let fork = c_repo(&c.json("fork", json!({"repo": repo}))); assert_eq!(fork, format!("{}/reviewed", contrib.account.handle)); let change = c.json("commit", json!({"repo": fork, "branch": "main", "message": "b", "changes": [{"path": "b", "content": "b\n"}]})); // A collaborator needs the owner's writes delegated to a gateway: the // appview, unless another is named. let (text, err) = o.call( "share", json!({"repo": repo, "with": contrib.account.handle, "write": true}), ); assert!(err && text.contains("`delegate` tool"), "{text}"); let (text, err) = c.call("create_branch", json!({"repo": repo, "branch": "theirs"})); assert!(err && text.contains("only its owner can"), "{text}"); let gateway = o.json("delegate", json!({}))["delegated"].clone(); assert!( gateway .as_str() .unwrap() .starts_with("did:web:localhost%3A"), "{gateway}" ); o.json("delegate", json!({"gateway": gateway})); o.json( "share", json!({"repo": repo, "with": contrib.account.handle, "write": true}), ); c.json("create_branch", json!({"repo": repo, "branch": "theirs"})); c.json("delete_branch", json!({"repo": repo, "branch": "theirs"})); o.json( "share", json!({"repo": repo, "with": contrib.account.handle, "revoke": true}), ); let pull = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "main", "title": "Add b", "body": "small"})); let pull = pull["uri"].as_str().unwrap().to_string(); let issue = c.json("open_issue", json!({"repo": repo, "title": "Missing c"}))["uri"] .as_str() .unwrap() .to_string(); let listed = |state: &str| -> Vec { o.json( "list_items", json!({"repo": repo, "kind": "pull", "state": state}), ) .as_array() .unwrap() .clone() }; wait_until(|| listed("open").len() == 1); // Authors come with their handles. assert_eq!(listed("open")[0]["handle"], contrib.account.handle.as_str()); wait_until(|| { o.json("list_items", json!({"repo": repo})) .as_array() .unwrap() .len() == 2 }); o.json( "comment", json!({"repo": repo, "uri": pull, "body": "good", "verdict": "approve"}), ); let (text, err) = o.call( "comment", json!({"repo": repo, "uri": pull, "body": "?", "verdict": "maybe"}), ); assert!(err, "{text}"); wait_until(|| { o.json("show_item", json!({"repo": repo, "uri": pull}))["comments"] .as_array() .unwrap() .len() == 1 }); let shown = o.json("show_item", json!({"repo": repo, "uri": pull})); assert_eq!(shown["item"]["handle"], contrib.account.handle.as_str()); assert_eq!(shown["item"]["approvals"], 1); // The pull's author points it at the head branch it already names. assert_eq!( c.json( "retarget_pull", json!({"repo": repo, "uri": pull, "head": "main"}) )["retargeted"], pull ); let (text, err) = o.call( "retarget_pull", json!({"repo": repo, "uri": pull, "base": "nope"}), ); assert!(err, "{text}"); // Before merging: what it brings in, that it merges, and the review. let shown = o.json("show_item", json!({"repo": repo, "uri": pull})); assert_eq!(shown["compare"]["mergeable"], "fast-forward"); assert_eq!(shown["compare"]["head"], change["sha"]); assert_eq!(shown["compare"]["commits"].as_array().unwrap().len(), 1); assert_eq!(shown["comments"][0]["body"], "good"); assert_eq!(shown["comments"][0]["verdict"], "approve"); assert_eq!( shown["comments"][0]["handle"], owner.account.handle.as_str() ); let diff = o.ok("pull_diff", json!({"repo": repo, "uri": pull})); assert!(diff.contains("+b"), "{diff}"); assert_eq!( o.json("merge", json!({"repo": repo, "uri": pull}))["sha"], change["sha"] ); wait_until(|| listed("merged").len() == 1); let (text, err) = o.call("merge", json!({"repo": repo, "uri": pull})); assert!(err, "{text}"); let shown = o.json("show_item", json!({"repo": repo, "uri": pull})); assert_eq!(shown["compare"]["mergeable"], "merged"); // A pull that conflicts says where. o.json("commit", json!({"repo": repo, "branch": "main", "message": "mine", "changes": [{"path": "a", "content": "owner\n"}]})); c.json("commit", json!({"repo": fork, "branch": "clash", "message": "theirs", "changes": [{"path": "a", "content": "contrib\n"}]})); let clash = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "clash", "title": "Clash"})); let clash = clash["uri"].as_str().unwrap().to_string(); let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"]; assert_eq!(compared["mergeable"], "conflict"); assert_eq!(compared["conflicts"], json!(["a"])); let (text, err) = o.call("merge", json!({"repo": repo, "uri": clash})); assert!(err && text.contains("conflicts in a"), "{text}"); // One that touches other files merges cleanly though main moved on. c.json("commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]})); let aside = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "aside", "title": "Aside"})); let aside = aside["uri"].as_str().unwrap().to_string(); let compared = &o.json("show_item", json!({"repo": repo, "uri": aside}))["compare"]; assert_eq!(compared["mergeable"], "clean"); // Renaming a branch takes the pulls on it along. o.json("create_branch", json!({"repo": repo, "branch": "develop"})); let onto = c.json("open_pull", json!({"repo": repo, "base": "develop", "head_repo": fork, "head_branch": "clash", "title": "Onto develop"})); let onto = onto["uri"].as_str().unwrap().to_string(); wait_until(|| listed("open").iter().any(|i| i["uri"] == onto.as_str())); let renamed = o.json( "rename_branch", json!({"repo": repo, "from": "develop", "to": "next"}), ); assert_eq!(renamed["retargeted"], json!([onto])); wait_until(|| { o.json("show_item", json!({"repo": repo, "uri": onto}))["item"]["base"] == "next" }); let (text, err) = o.call( "rename_branch", json!({"repo": repo, "from": "main", "to": "trunk"}), ); assert!(err && text.contains("default branch"), "{text}"); // A pull whose head is gone still shows, and says so. c.json("delete_branch", json!({"repo": fork, "branch": "clash"})); let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"]; assert!( compared["error"] .as_str() .unwrap() .contains("has no branch clash"), "{compared}" ); // An issue has nothing to compare. let shown = o.json("show_item", json!({"repo": repo, "uri": issue})); assert!(shown.get("compare").is_none(), "{shown}"); assert_eq!( o.json( "set_state", json!({"repo": repo, "uri": issue, "state": "closed"}) )["state"], "closed" ); let (text, err) = o.call( "set_state", json!({"repo": repo, "uri": issue, "state": "merged"}), ); assert!(err, "{text}"); // Offered to the contributor, withdrawn, offered again, and taken up. let them = contrib.account.handle.clone(); o.json("transfer_repo", json!({"repo": repo, "to": them})); o.json("transfer_repo", json!({"repo": repo})); let offered = o.json( "transfer_repo", json!({"repo": repo, "to": format!("{them}/kept")}), ); assert_eq!(offered["offeredTo"], format!("knotless://{them}/kept")); let moved = c.json("accept_transfer", json!({"repo": repo})); assert_eq!( moved["url"], format!("knotless://{}/kept", contrib.account.did) ); for (tool, args) in [ ("transfer_repo", json!({"repo": "nobody.test/x", "to": "x"})), ("accept_transfer", json!({"repo": "nobody.test/x"})), ("fork", json!({"repo": "nobody.test/x"})), ( "open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "none", "title": "t"}), ), ("open_issue", json!({"repo": "nobody.test/x", "title": "t"})), ("list_items", json!({"repo": "nobody.test/x"})), ("pull_diff", json!({"repo": repo, "uri": issue})), ( "show_item", json!({"repo": repo, "uri": "at://did:plc:nobody/dev.knotless.issue/x"}), ), ] { let (text, err) = o.call(tool, args.clone()); assert!(err, "{tool} {args}: {text}"); } } #[test] fn agents_moderate() { let rt = tokio::runtime::Runtime::new().unwrap(); let (base, _appview, _proxy) = appview(&rt); let mut owner = logged_in(); owner.set("KNOTLESS_APPVIEW", &base); // Delegating is approved in the browser. let browser = browser(&owner.account, ""); owner.set("KNOTLESS_BROWSER", &browser); let mut operator = logged_in(); operator.set("KNOTLESS_APPVIEW", &base); operator.set("KNOTLESS_ADMIN_PASSWORD", "hunter2"); let (o, c) = (Client::new(&owner), Client::new(&operator)); let repo = c_repo(&o.json("create_repo", json!({"name": "moderated"}))); o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]})); let issue = c.json("open_issue", json!({"repo": repo, "title": "Spam"}))["uri"] .as_str() .unwrap() .to_string(); let items = || { o.json("list_items", json!({"repo": repo})) .as_array() .unwrap() .len() }; wait_until(|| items() == 1); let hidden = o.json("hide_item", json!({"repo": repo, "uri": issue})); assert_eq!(hidden["hidden"], true); wait_until(|| items() == 0); o.json( "hide_item", json!({"repo": repo, "uri": issue, "hidden": false}), ); wait_until(|| items() == 1); let them = operator.account.handle.clone(); assert_eq!( o.json("block_account", json!({"account": them}))["blocked"], true ); wait_until(|| items() == 0); o.json("block_account", json!({"account": them, "blocked": false})); wait_until(|| items() == 1); o.json("block_account", json!({"account": them, "repo": repo})); wait_until(|| items() == 0); o.json( "block_account", json!({"account": them, "repo": repo, "blocked": false}), ); wait_until(|| items() == 1); let report = c.json( "report", json!({"subject": repo, "reason": "spam", "text": "all of it"}), ); assert_eq!(report["id"], 1); assert_eq!( report["subject"]["uri"], format!("at://{}/dev.knotless.repo/moderated", owner.account.did) ); c.json( "report", json!({"subject": owner.account.handle, "reason": "rude"}), ); let reports = c.json("list_reports", json!({})); assert_eq!(reports["reports"].as_array().unwrap().len(), 2); let down = c.json("takedown", json!({"subject": issue})); assert_eq!(down["takedown"]["applied"], true); wait_until(|| items() == 0); let status = c.json("moderation_status", json!({"subject": issue})); assert_eq!(status["takedown"]["applied"], true); c.json("takedown", json!({"subject": issue, "applied": false})); wait_until(|| items() == 1); for (who, tool, args) in [ (&c, "hide_item", json!({"repo": repo, "uri": issue})), (&o, "block_account", json!({"account": "nobody.test"})), (&c, "report", json!({"subject": repo, "reason": "boring"})), (&o, "takedown", json!({"subject": issue})), (&o, "moderation_status", json!({"subject": issue})), (&o, "list_reports", json!({})), ] { let (text, err) = who.call(tool, args.clone()); assert!(err, "{tool} {args}: {text}"); } } fn c_repo(v: &Value) -> String { v["repo"].as_str().unwrap().to_string() }