use super::*;
use crate::xrpc::social::{Avatar, Social, SocialRecord};
const REPO: &str = "did:plc:abcdefghijklmnopqrstuvwx";
const ALICE: &str = "did:plc:alice";
const AVATAR: &str = "bafkreigh2akiscaildc7gnvtklbsfhdgwz72eolmpckbqr5ej26byp3uli";
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n an avatar";
fn json(body: &[u8]) -> serde_json::Value {
serde_json::from_slice(body).unwrap()
}
fn knot() -> (u16, Nginx, tempfile::TempDir) {
knot_with(1)
}
fn knot_with(tickets: usize) -> (u16, Nginx, tempfile::TempDir) {
let nginx_binary = PathBuf::from(
std::env::var_os("NGINX_BINARY")
.expect("NGINX_BINARY must name the real nginx binary for this ignored test"),
);
let temp = tempfile::tempdir().unwrap();
let (config, manifest, generation) = test_fixture(temp.path());
let fixture = crate::atproto::tests::fixture("sha1");
let mut settled = crate::atproto::tests::settle_fixture(&fixture);
settled
.rows
.social
.extend((1..=tickets).map(|n| SocialRecord {
collection: "org.tangled.track.ticket".into(),
rkey: format!("t{n}"),
uri: format!("at://{REPO}/org.tangled.track.ticket/t{n}"),
cid: format!("bafyreiticket{n}"),
value: serde_json::json!({
"title": { "text": "hi" },
"state": "open",
"createdAt": format!("2024-01-01T00:00:{n:02}Z"),
}),
author: Some(ALICE.into()),
editor: Some(ALICE.into()),
}));
let avatars_dir = temp.path().join("avatars");
std::fs::create_dir_all(&avatars_dir).unwrap();
std::fs::write(avatars_dir.join(format!("{AVATAR}.png")), PNG).unwrap();
for size in crate::xrpc::social::AVATAR_SIZES {
std::fs::write(
avatars_dir.join(crate::xrpc::social::sized_avatar(AVATAR, size)),
format!("RIFF {size}"),
)
.unwrap();
}
let social = Social {
hostname: "knot.test".into(),
owner: Some("did:plc:owner".into()),
handles: [(ALICE.to_string(), "alice.test".to_string())].into(),
avatars: [(
ALICE.to_string(),
Avatar {
cid: AVATAR.into(),
source: AVATAR.into(),
sized: true,
ext: "png".into(),
size: PNG.len() as u64,
},
)]
.into(),
avatars_dir: Some(avatars_dir),
profiles: [(
ALICE.to_string(),
serde_json::json!({ "description": "hi (˶ᵔ ᵕ ᵔ˶)", "pronouns": "she/her" })
.as_object()
.unwrap()
.clone(),
)]
.into(),
handles_digest: String::new(),
};
crate::atproto::emit_with(&generation, std::slice::from_ref(&settled), &social).unwrap();
crate::blobs::emit(temp.path(), &generation, &[], &Default::default()).unwrap();
crate::nginx::emit_at(&config, &manifest, &generation, &generation).unwrap();
let (port, nginx) = start_nginx(&nginx_binary, &generation);
(port, nginx, temp)
}
fn error(port: u16, target: &str) -> (u16, String) {
let (status, _, body) = http(port, "GET", target, &[], b"");
let error = json(&body)["error"]
.as_str()
.unwrap_or_default()
.to_string();
(status, error)
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn social_reads_answer_through_nginx() {
let (port, _nginx, _temp) = knot();
let get = |target: &str| http(port, "GET", target, &[], b"");
let (status, head, body) =
get("/xrpc/org.tangled.temp.repo.getRepoByName?owner=did%3Aplc%3Aowner&name=demo");
assert_eq!(status, 200, "{head}");
assert!(
head.to_ascii_lowercase().contains("application/json"),
"{head}"
);
assert!(head.contains("Access-Control-Allow-Origin: *"), "{head}");
assert_eq!(json(&body)["knot"], "knot.test");
let (status, _, body) = get(&format!(
"/xrpc/org.tangled.search.query?repoDid={REPO}&kind=issue&q=is%3Aopen&limit=30"
));
assert_eq!(status, 200);
let page = json(&body);
assert_eq!(page["hits"][0]["author"], ALICE);
assert_eq!(page["stateCounts"]["open"], 1);
let (status, head, body) = get(&format!(
"/xrpc/org.tangled.search.query?repoDid={REPO}&kind=issue&q=some+words"
));
assert_eq!(status, 400, "{head}");
assert!(
head.to_ascii_lowercase().contains("application/json"),
"{head}"
);
assert_eq!(json(&body)["error"], "InvalidQuery");
let (status, _, body) = get(&format!(
"/xrpc/org.tangled.repo.countIssues?subject={REPO}&state=open"
));
assert_eq!(status, 200);
assert_eq!(json(&body)["count"], 1);
let ticket = crate::xrpc::form_encoded(&format!("at://{REPO}/org.tangled.track.ticket/t1"));
let (status, _, body) = get(&format!(
"/xrpc/org.tangled.track.getTicketView?uri={ticket}"
));
assert_eq!(status, 200);
assert_eq!(json(&body)["author"]["handle"], "alice.test");
let (status, _, body) =
get("/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=alice.test");
assert_eq!(status, 200);
assert_eq!(json(&body)["did"], ALICE);
let (status, _, _) = get(&format!(
"/xrpc/org.tangled.track.getTicketView?uri=at://{REPO}/org.tangled.track.ticket/nope"
));
assert_eq!(status, 404);
let actor = crate::xrpc::form_encoded(&format!("at://{ALICE}/org.tangled.actor.profile/self"));
let (status, _, body) = get(&format!("/xrpc/org.tangled.actor.getProfile?actor={actor}"));
assert_eq!(status, 200);
let profile = json(&body);
assert_eq!(profile["value"]["description"], "hi (˶ᵔ ᵕ ᵔ˶)");
assert_eq!(profile["value"]["pronouns"], "she/her");
assert_eq!(profile["value"]["avatar"]["ref"]["$link"], AVATAR);
let (status, _, body) = get("/xrpc/org.tangled.publicKey.listKeys?subject=did%3Aplc%3Aanyone");
assert_eq!(status, 200);
assert_eq!(json(&body), serde_json::json!({ "items": [] }));
for (target, served, mime) in [
(format!("/avatar/{ALICE}"), PNG.to_vec(), "image/png"),
(
format!("/avatar/{ALICE}?size=500"),
PNG.to_vec(),
"image/png",
),
(
format!("/avatar/{ALICE}?size=big"),
PNG.to_vec(),
"image/png",
),
(
"/avatar/did%3Aplc%3Aalice?size=48&v=yumak".to_string(),
b"RIFF 96".to_vec(),
"image/webp",
),
(
format!("/avatar/{ALICE}?size=42"),
b"RIFF 42".to_vec(),
"image/webp",
),
(
format!("/avatar/{ALICE}?size=192"),
b"RIFF 192".to_vec(),
"image/webp",
),
] {
let (status, head, body) = get(&target);
assert_eq!(status, 200, "{target}: {head}");
assert!(
head.contains(&format!("Content-Type: {mime}")),
"{target}: {head}"
);
assert!(head.contains("X-Content-Type-Options: nosniff"), "{head}");
assert_eq!(body, served, "{target}");
}
for target in ["/avatar/did:plc:bob", "/avatar/", "/_ks_avatars/x.png"] {
let (status, head, _) = get(target);
assert_eq!(status, 404, "{target}: {head}");
}
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn arrays_of_two_different_values_are_refused() {
let (port, _nginx, _temp) = knot();
let alice = crate::xrpc::form_encoded(&format!("at://{ALICE}/org.tangled.actor.profile/self"));
let profiles = |query: &str| format!("/xrpc/org.tangled.actor.getProfiles?{query}");
let repos = |query: &str| format!("/xrpc/org.tangled.repo.getReposByRepoDids?{query}");
for target in [
profiles(&format!("actors={alice}")),
profiles(&format!("actors={alice}&actors={alice}")),
repos(&format!("dids={REPO}")),
repos(&format!("dids={REPO}&dids={REPO}")),
repos(&format!("repoDids={REPO}&repoDids={REPO}")),
] {
let (status, _, body) = http(port, "GET", &target, &[], b"");
assert_eq!(status, 200, "{target}");
assert_eq!(
json(&body)["items"].as_array().unwrap().len(),
1,
"{target}"
);
}
for target in [
profiles(&format!(
"actors={alice}&actors=at%3A%2F%2Fdid%3Aplc%3Abob%2Forg.tangled.actor.profile%2Fself"
)),
repos(&format!("dids={REPO}&dids=did%3Aplc%3Aother")),
repos(&format!("dids=did:plc:other&limit=1&dids={REPO}")),
repos(&format!("repoDids={REPO}&repoDids=did:plc:other")),
] {
assert_eq!(error(port, &target), (404, "NotFound".into()), "{target}");
}
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn whole_lists_refuse_later_pages_and_other_orders() {
let (port, _nginx, _temp) = knot();
let lists = [
("org.tangled.repo.listRepos", "did%3Aplc%3Aowner"),
("org.tangled.repo.listCollaborators", REPO),
("org.tangled.repo.listArtifacts", REPO),
("org.tangled.label.listDefinitions", REPO),
];
for (nsid, subject) in lists {
let target = |query: &str| format!("/xrpc/{nsid}?subject={subject}{query}");
for query in [
"",
"&limit=100",
"&offset=0",
"&order=desc",
"&cursor=",
"&limit=1000&order=DESC",
] {
assert_eq!(
error(port, &target(query)),
(200, String::new()),
"{nsid}{query}"
);
}
for query in [
"&cursor=3",
"&offset=30",
"&offset=0&offset=30",
"&order=asc",
"&ORDER=asc",
"&limit=100&cursor=abc",
] {
assert_eq!(
error(port, &target(query)),
(404, "NotFound".into()),
"{nsid}{query}"
);
}
}
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn ticket_reads_refuse_filters_and_pages_they_dont_keep() {
let (port, _nginx, _temp) = knot();
let ticket = crate::xrpc::form_encoded(&format!("at://{REPO}/org.tangled.track.ticket/t1"));
let reads = [
format!("/xrpc/org.tangled.repo.listIssues?subject={REPO}"),
format!("/xrpc/org.tangled.repo.listPulls?subject={REPO}"),
format!("/xrpc/org.tangled.repo.countIssues?subject={REPO}&state=open"),
format!("/xrpc/org.tangled.repo.countPulls?subject={REPO}&status=open"),
format!("/xrpc/org.tangled.feed.listComments?subject={ticket}&order=desc&limit=100"),
format!("/xrpc/org.tangled.temp.review.listPatches?ticket={ticket}&order=asc&limit=500"),
format!("/xrpc/org.tangled.search.query?repoDid={REPO}&kind=issue"),
];
for read in &reads {
assert_eq!(error(port, read), (200, String::new()), "{read}");
assert_eq!(
error(port, &format!("{read}&author=")),
(200, String::new()),
"{read}"
);
}
let lists = &reads[..2];
for read in lists {
for query in ["&limit=100", "&offset=0", "&order=desc"] {
assert_eq!(
error(port, &format!("{read}{query}")),
(200, String::new()),
"{read}{query}"
);
}
for query in ["&cursor=1", "&offset=1", "&order=asc"] {
assert_eq!(
error(port, &format!("{read}{query}")),
(404, "NotFound".into()),
"{read}{query}"
);
}
}
for read in &reads[..4] {
let refused = format!("{read}&author={ALICE}");
assert_eq!(error(port, &refused), (404, "NotFound".into()), "{refused}");
}
for read in &reads[4..6] {
for query in ["&cursor=1", "&offset=1"] {
assert_eq!(
error(port, &format!("{read}{query}")),
(404, "NotFound".into()),
"{read}{query}"
);
}
}
let search = &reads[6];
for query in [
"&nsid=org.tangled.track.ticket",
"&sort=relevance",
"&sort=created-desc",
"&label=",
"&limit=10&offset=0",
] {
assert_eq!(
error(port, &format!("{search}{query}")),
(200, String::new()),
"{query}"
);
}
for query in [
"&author=did%3Aplc%3Aalice",
"&nsid=org.tangled.feed.comment",
"&label=at%3A%2F%2Fdid%3Aplc%3Ax%2Forg.tangled.label.definition%2Fbug",
"&label=&label=bug",
"&since=2024-01-01T00%3A00%3A00Z",
"&until=2024-01-01T00%3A00%3A00Z",
"&sort=created-asc",
] {
assert_eq!(
error(port, &format!("{search}{query}")),
(404, "NotFound".into()),
"{query}"
);
}
}
#[test]
#[ignore = "requires nginx; set NGINX_BINARY"]
fn search_follows_the_cursor_it_hands_out() {
let (port, _nginx, _temp) = knot_with(25);
let search = |query: &str| {
let target =
format!("/xrpc/org.tangled.search.query?repoDid={REPO}&kind=issue&limit=10{query}");
let (status, _, body) = http(port, "GET", &target, &[], b"");
(status, json(&body))
};
let mut seen = Vec::new();
let mut cursor = String::new();
loop {
let query = match cursor.as_str() {
"" => String::new(),
cursor => format!("&cursor={cursor}"),
};
let (status, page) = search(&query);
assert_eq!(status, 200, "{query}");
seen.extend(
page["hits"]
.as_array()
.unwrap()
.iter()
.map(|hit| hit["uri"].as_str().unwrap().to_string()),
);
match page["cursor"].as_str() {
Some(next) => cursor = next.to_string(),
None => break,
}
}
let newest_first: Vec<String> = (1..=25)
.rev()
.map(|n| format!("at://{REPO}/org.tangled.track.ticket/t{n}"))
.collect();
assert_eq!(seen, newest_first);
assert_eq!(search("&offset=10").1, search("&cursor=10").1);
for query in ["&offset=10&cursor=0", "&cursor=1&offset=0", "&cursor=nope"] {
let (status, page) = search(query);
assert_eq!(
(status, page["error"].as_str()),
(400, Some("InvalidRequest")),
"{query}"
);
}
}