diff --git a/crates/vibescrobble-query/tests/invented.rs b/crates/vibescrobble-query/tests/invented.rs new file mode 100644 index 00000000..10078b44 --- /dev/null +++ b/crates/vibescrobble-query/tests/invented.rs @@ -0,0 +1,334 @@ +//! A visitor cannot make the query service ask the index about something +//! the board does not hold. +//! +//! Every public route that takes a DID, a handle, a picture's path, a week +//! or a repository answers from the mirrored view, and asks the index only +//! about an account the view holds: a picture it names, the usage of an +//! account in it, a week of a subject in it between the ledgers' horizon and +//! now. The index here counts every question it is asked past the mirror's +//! own seed and stream. Real accounts are asked about and answered; a burst +//! of made-up ones is answered 404, or a badge with nothing on it, and the +//! index is asked nothing more. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::Request; +use axum::middleware::{self, Next}; +use reqwest::StatusCode; +use serde_json::json; +use tokio::net::TcpListener; +use vibescrobble_index::avatar::{self, Kind}; +use vibescrobble_index::nsid::SCROBBLE; +use vibescrobble_index::tally::Week; +use vibescrobble_index::view::{Agent, Human, Operator, Server}; +use vibescrobble_index::Shared; +use vibescrobble_query::card::Cards; +use vibescrobble_query::{app_sharing, mirror, Sharing}; + +const PATIENCE: Duration = Duration::from_secs(30); + +const OPERATOR: &str = "did:plc:kq3vv6mbfqhzpldiwtoeyrxs"; +const SERVER: &str = "did:web:agents.example"; +const AGENT: &str = "did:web:basalt-kinkajou.agents.example"; +const HANDLE: &str = "basalt-kinkajou.agents.example"; + +/// How many made-up values of each kind are asked for. +const BURST: usize = 40; + +fn jpeg(width: u32, height: u32) -> Vec { + let picture = image::RgbImage::from_fn(width, height, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 180]) + }); + let mut out = std::io::Cursor::new(Vec::new()); + picture + .write_to(&mut out, image::ImageFormat::Jpeg) + .expect("a JPEG encodes"); + out.into_inner() +} + +/// The week now, waiting out the first minutes of a Monday so a line a few +/// minutes old is in it. +async fn this_week() -> Week { + loop { + let now = time::OffsetDateTime::now_utc(); + if Week::of(now) == Week::of(now - time::Duration::minutes(10)) { + return Week::of(now); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +/// An index holding a person with a banner and an agent with an avatar, +/// a line and the tokens it spent, and the paths of both pictures. +async fn index() -> (Shared, String, String) { + let index = Shared::new(); + let (avatar, banner) = (jpeg(144, 144), jpeg(600, 200)); + let (avatar_cid, banner_cid) = (avatar::cid_of(&avatar), avatar::cid_of(&banner)); + index + .avatars + .put(Kind::Avatar, AGENT, &avatar_cid, Bytes::from(avatar)) + .await; + index + .avatars + .put(Kind::Banner, OPERATOR, &banner_cid, Bytes::from(banner)) + .await; + let avatar = Kind::Avatar.path(AGENT, &avatar_cid); + let banner = Kind::Banner.path(OPERATOR, &banner_cid); + let at = (time::OffsetDateTime::now_utc() - time::Duration::minutes(3)) + .format(&time::format_description::well_known::Rfc3339) + .expect("a time"); + let line = vibescrobble_index::read_record( + AGENT, + &format!("at://{AGENT}/{SCROBBLE}/3kinka00000a"), + json!({ + "$type": SCROBBLE, + "text": "quillwort", + "emoji": "🪨", + "createdAt": at, + "model": "claude-opus-5-5", + "usage": { "input": 10, "output": 20, "cacheRead": 0, "cacheWrite": 0 }, + }), + ) + .expect("a scrobble"); + let mut announced = Vec::new(); + { + let mut view = index.view.lock().await; + view.use_ledgers(vibescrobble_index::ledgers::Store::memory()); + announced.extend(view.see_server(Server { + did: SERVER.to_owned(), + base_url: "http://agents.example".to_owned(), + handle: "agents.example".to_owned(), + zone: "agents.example".to_owned(), + operator: Some(OPERATOR.to_owned()), + })); + announced.extend(view.see_operator(Operator { + did: OPERATOR.to_owned(), + handle: Some("operator.example".to_owned()), + slot: None, + place: None, + avatar: None, + banner: Some(banner.clone()), + display_name: None, + description: None, + })); + announced.extend(view.see_agent(Agent { + did: AGENT.to_owned(), + agent_id: "basalt-kinkajou".to_owned(), + handle: Some(HANDLE.to_owned()), + avatar: Some(avatar.clone()), + banner: None, + display_name: None, + description: None, + server: SERVER.to_owned(), + operator: Some(Human { + did: OPERATOR.to_owned(), + handle: Some("operator.example".to_owned()), + }), + created_at: "2026-01-05T10:00:00Z".to_owned(), + parent: None, + agent_type: None, + registered_kind: None, + model: None, + status: None, + active_at: None, + dormant: false, + idle: false, + })); + announced.extend(view.see_scrobble(line)); + } + index.announce(announced).await; + (index, avatar, banner) +} + +/// Serves `index` on a port nobody chose, writing down every path it is +/// asked for but the mirror's seed and stream. +async fn counting(index: Shared) -> (String, Arc>>) { + let asked = Arc::new(Mutex::new(Vec::new())); + let noted = Arc::clone(&asked); + let router = vibescrobble_index::app(index).layer(middleware::from_fn( + move |request: Request, next: Next| { + let noted = Arc::clone(¬ed); + async move { + let path = request.uri().path().to_owned(); + if path != "/view" && path != "/events" { + noted.lock().unwrap().push(path); + } + next.run(request).await + } + }, + )); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("a port"); + let addr = listener.local_addr().expect("an address"); + tokio::spawn(async move { axum::serve(listener, router).await }); + (format!("http://{addr}"), asked) +} + +/// A query service mirroring `upstream`, served, once it holds the agent. +async fn query(upstream: &str) -> String { + let query = Shared::new() + .with_avatars(avatar::Avatars::upstream(upstream)) + .with_burn(vibescrobble_index::burn::Burn::upstream(upstream)); + tokio::spawn(mirror(upstream.to_owned(), query.clone())); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("a port"); + let addr = listener.local_addr().expect("an address"); + let router = app_sharing( + query.clone(), + Sharing { + public_url: Some("https://board.example".to_owned()), + cards: Cards::default(), + unwrapped: None, + index: Some(upstream.to_owned()), + }, + ); + tokio::spawn(vibescrobble_index::service::serve(listener, router)); + let deadline = tokio::time::Instant::now() + PATIENCE; + while query.view.lock().await.agent(AGENT).is_none() { + assert!( + tokio::time::Instant::now() < deadline, + "the mirror never seeded" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + format!("http://{addr}") +} + +async fn status(base: &str, path: &str) -> StatusCode { + reqwest::get(format!("{base}{path}")) + .await + .expect("it answers") + .status() +} + +/// `n` written in base32's letters, for a CID or a DID of its own. +fn letters(n: usize) -> String { + format!("{n:04}") + .bytes() + .map(|digit| char::from(b'a' + (digit - b'0'))) + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn made_up_accounts_pictures_and_weeks_cost_the_index_nothing() { + let week = this_week().await; + let (index, avatar, banner) = index().await; + let (upstream, asked) = counting(index).await; + let base = query(&upstream).await; + let count = || asked.lock().unwrap().len(); + + // Real ones are asked of the index, and answered. + for (path, want) in [ + (avatar.clone(), StatusCode::OK), + (banner.clone(), StatusCode::OK), + (format!("/usage/{AGENT}"), StatusCode::OK), + (format!("/usage/operator/{OPERATOR}"), StatusCode::OK), + (format!("/weeks/{AGENT}"), StatusCode::OK), + (format!("/weeks/{AGENT}/{week}"), StatusCode::OK), + (format!("/@{HANDLE}/w/{week}"), StatusCode::OK), + ] { + let before = count(); + assert_eq!(status(&base, &path).await, want, "{path}"); + assert!(count() > before, "{path} was not asked of the index"); + } + // A picture once read is held: asking again, or drawing it on a badge + // or a card, asks nothing more. + let held = count(); + for path in [ + avatar.clone(), + banner.clone(), + format!("/badge/{HANDLE}.svg"), + format!("/cards/{AGENT}"), + ] { + assert_eq!(status(&base, &path).await, StatusCode::OK, "{path}"); + } + assert_eq!(count(), held, "{:?}", &asked.lock().unwrap()[held..]); + + // A burst of made-up ones is asked of nobody. + let (avatar_cid, banner_cid) = ( + avatar.rsplit('/').next().expect("a CID"), + banner.rsplit('/').next().expect("a CID"), + ); + let past = week.horizon().previous(); + let mut invented: Vec<(String, StatusCode)> = vec![ + // A real CID under the other kind, or under somebody else. + ( + Kind::Avatar.path(OPERATOR, banner_cid), + StatusCode::NOT_FOUND, + ), + (Kind::Banner.path(AGENT, avatar_cid), StatusCode::NOT_FOUND), + ( + Kind::Avatar.path(OPERATOR, avatar_cid), + StatusCode::NOT_FOUND, + ), + // A real subject's weeks past what a ledger keeps, or to come. + (format!("/weeks/{AGENT}/{past}"), StatusCode::NOT_FOUND), + (format!("/weeks/{AGENT}/1970-W02"), StatusCode::NOT_FOUND), + (format!("/cards/{AGENT}/w/{past}"), StatusCode::NOT_FOUND), + (format!("/@{HANDLE}/w/{past}"), StatusCode::NOT_FOUND), + ( + format!("/weeks/{AGENT}/{}", week.next()), + StatusCode::NOT_FOUND, + ), + ( + format!("/cards/{AGENT}/y/{}", week.year), + StatusCode::NOT_FOUND, + ), + ]; + for n in 0..BURST { + let tag = letters(n); + let did = format!("did:plc:invented{tag}{tag}abcd"); + let web = format!("did:web:invented-{tag}.example"); + let handle = format!("invented-{tag}.example"); + let cid = format!("bafkreiinvented{tag}"); + let repo = format!("tangled.org/invented.example/{tag}"); + for (path, want) in [ + (Kind::Avatar.path(&did, &cid), StatusCode::NOT_FOUND), + (Kind::Banner.path(&web, &cid), StatusCode::NOT_FOUND), + (Kind::Avatar.path(AGENT, &cid), StatusCode::NOT_FOUND), + (Kind::Banner.path(OPERATOR, &cid), StatusCode::NOT_FOUND), + (format!("/usage/{did}"), StatusCode::NOT_FOUND), + (format!("/usage/operator/{web}"), StatusCode::NOT_FOUND), + (format!("/agents/{handle}"), StatusCode::NOT_FOUND), + (format!("/operators/{did}"), StatusCode::NOT_FOUND), + (format!("/@{handle}"), StatusCode::NOT_FOUND), + (format!("/@{did}"), StatusCode::NOT_FOUND), + (format!("/s/{web}/3kinka00000a"), StatusCode::NOT_FOUND), + (format!("/cards/{did}"), StatusCode::NOT_FOUND), + (format!("/weeks/{did}"), StatusCode::NOT_FOUND), + (format!("/weeks/{web}/{week}"), StatusCode::NOT_FOUND), + (format!("/cards/{did}/w/{week}/og"), StatusCode::NOT_FOUND), + (format!("/@{handle}/w/{week}"), StatusCode::NOT_FOUND), + ( + format!("/badge/{handle}.json?repo={repo}"), + StatusCode::NOT_FOUND, + ), + (format!("/badge/{HANDLE}.svg?repo={repo}"), StatusCode::OK), + (format!("/badge/{HANDLE}.json?repo={repo}"), StatusCode::OK), + ( + format!("/scrobbles?operator={did}&server={web}"), + StatusCode::OK, + ), + (format!("/agents?operator={did}"), StatusCode::OK), + ] { + invented.push((path, want)); + } + } + let before = count(); + for (path, want) in &invented { + assert_eq!(status(&base, path).await, *want, "{path}"); + } + let after = asked.lock().unwrap()[before..].to_vec(); + assert!( + after.is_empty(), + "{} made-up requests asked the index {after:?}", + invented.len() + ); + + // The real ones are still answered after it. + assert_eq!(status(&base, &avatar).await, StatusCode::OK); + assert_eq!( + status(&base, &format!("/usage/{AGENT}")).await, + StatusCode::OK + ); +}