diff --git a/crates/ipx/src/metrics.rs b/crates/ipx/src/metrics.rs index 0f2910d..255cd19 100644 --- a/crates/ipx/src/metrics.rs +++ b/crates/ipx/src/metrics.rs @@ -158,6 +158,12 @@ impl Stats { } } + /// What `who` has been billed on `key`: items judged, Signals fit, input and output tokens. + #[cfg(test)] + pub fn billed(&self, who: &str, key: &'static str) -> [f64; 4] { + self.people.lock().unwrap().get(&(who.to_owned(), key)).copied().unwrap_or_default() + } + /// Marks the worker as on a job of `kind` until what it returns is dropped, however the job /// ends: finished, failed or stopped. pub fn job(&'static self, kind: &'static str) -> Job { diff --git a/crates/ipx/src/signals.rs b/crates/ipx/src/signals.rs index 512eb60..1e455cc 100644 --- a/crates/ipx/src/signals.rs +++ b/crates/ipx/src/signals.rs @@ -405,6 +405,9 @@ pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> { /// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries /// this trace's `traceparent`, as every call out of ipx does. async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result { + #[cfg(test)] + let url = tests::jev(); + #[cfg(not(test))] let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned()); let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT); if let Some(tp) = ipx_directory::otel::traceparent() { @@ -535,8 +538,172 @@ fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; + + /// Calls the stand-in has had, by the key they came with. + static CALLS: Mutex> = Mutex::new(BTreeMap::new()); + + /// A stand-in for TypeSafe's Jev, as the browser suite's fixture server is: on a thread and + /// runtime of its own, so it outlives any one test's. It answers by the key: `refused-` as a + /// key it does not know, `slow-` as over its limits, `malformed-` as a request it cannot read, + /// `offlist-` with an icon it was not offered; anything else judges an item to fit when its + /// text says "fits", and picks the icon labelled robot. + pub(crate) fn jev() -> String { + static AT: OnceLock = OnceLock::new(); + AT.get_or_init(|| { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.set_nonblocking(true).unwrap(); + let at = l.local_addr().unwrap(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(async move { + let app = axum::Router::new().route("/", axum::routing::post(stand_in)); + axum::serve(tokio::net::TcpListener::from_std(l).unwrap(), app).await.unwrap(); + }); + }); + format!("http://{at}/") + }) + .clone() + } + + async fn stand_in( + headers: axum::http::HeaderMap, + axum::Json(body): axum::Json, + ) -> axum::response::Response { + use axum::response::IntoResponse; + let key = + headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").trim_start_matches("Bearer "); + *CALLS.lock().unwrap().entry(key.to_owned()).or_default() += 1; + let refuse = |status: u16, detail: &str| { + ( + axum::http::StatusCode::from_u16(status).unwrap(), + [("retry-after", "1")], + format!(r#"{{"detail":"{detail}"}}"#), + ) + .into_response() + }; + if key.starts_with("refused-") { + return refuse(401, "invalid api key"); + } else if key.starts_with("slow-") { + return refuse(429, "rate limited"); + } else if key.starts_with("malformed-") { + return refuse(422, "criteria should be an object"); + } + let fits = body["state"].as_str().is_some_and(|s| s.contains("fits")); + let mut answers = serde_json::Map::new(); + for (id, q) in body["questions"].as_object().unwrap() { + let a = if q["type"] == "choice" { + let robot = q["criteria"].as_object().unwrap().iter().find(|(_, l)| l.as_str() == Some("robot")); + let pick = if key.starts_with("offlist-") { "solid/nope" } else { robot.map_or("", |(k, _)| k) }; + serde_json::json!({ "choice": pick }) + } else { + serde_json::json!({ "noul": if fits { 0.9 } else { 0.1 } }) + }; + answers.insert(id.clone(), a); + } + axum::Json(serde_json::json!({ "answers": answers, "usage": { "input_tokens": 100, "output_tokens": 3 } })) + .into_response() + } + + fn calls_with(key: &str) -> usize { + CALLS.lock().unwrap().get(key).copied().unwrap_or(0) + } + + /// Someone with a key, a Signal and a feed of their own holding these items; their Signal's id. + async fn person(ctx: &Ctx, name: &str, key: &str, items: &[(&str, &str)]) -> i64 { + let id = ctx.db.create_user(name, None, false).await.unwrap(); + ctx.db.set_typesafe_key(id, Some(key)).await.unwrap(); + let feed = format!("feed-{name}"); + let mut sql = format!("INSERT INTO subscriptions (user_id, feed_id) VALUES ({id}, '{feed}');"); + for (guid, title) in items { + sql += &format!( + "INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('{feed}', '{guid}', '{title}', {});", + db::now() + ); + } + ctx.db.exec_for_test(&sql).await.unwrap(); + ctx.db.add_signal(id, "Rust", &["rust".into()], None).await.unwrap() + } + + async fn verdicts(ctx: &Ctx, signal: i64) -> Vec { + ctx.db + .strings_for_test(&format!( + "SELECT guid || CASE WHEN hit THEN ' fits' ELSE ' not' END FROM signal_verdicts + WHERE signal_id = {signal} ORDER BY guid" + )) + .await + } + + #[tokio::test] + async fn a_pass_judges_each_new_item_once_keeps_its_verdicts_and_bills_its_owner() { + let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await; + let signal = person(&ctx, "sig-ann", "good-ann", &[("a", "this one fits"), ("b", "about gardening")]).await; + assert!(!pass(&ctx).await.unwrap(), "two items is not a full batch"); + assert_eq!(verdicts(&ctx, signal).await, ["a fits", "b not"]); + assert_eq!(calls_with("good-ann"), 2, "a call an item"); + assert!(!pass(&ctx).await.unwrap()); + assert_eq!(calls_with("good-ann"), 2, "nothing is asked twice"); + // Items judged, Signals fit, input and output tokens, on the person's own key (#278). + assert_eq!(STATS.billed("sig-ann", "own"), [2.0, 1.0, 200.0, 6.0]); + assert_eq!(STATS.billed("sig-ann", "server"), [0.0; 4]); + } + + #[tokio::test] + async fn a_refused_key_stops_its_owners_judging_and_nobody_elses() { + let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await; + let ann = person(&ctx, "sig-ann2", "good-ann2", &[("a", "fits")]).await; + let bob = person(&ctx, "sig-bob", "refused-bob", &[("b", "fits")]).await; + let refused_before = STATS.jev_calls[1].load(Relaxed); + assert!(pass(&ctx).await.unwrap(), "goes round again for whoever is left"); + assert_eq!(verdicts(&ctx, ann).await, ["a fits"]); + assert!(verdicts(&ctx, bob).await.is_empty(), "left unjudged, not judged a miss"); + let bob_id = ctx.db.user_by_name("sig-bob").await.unwrap().unwrap().id; + assert!(ctx.signals_refused.lock().unwrap().contains(&bob_id)); + assert!(STATS.jev_calls[1].load(Relaxed) > refused_before, "counted as key_refused"); + assert!(!pass(&ctx).await.unwrap()); + assert_eq!(calls_with("refused-bob"), 1, "not sent again until a new key is saved"); + } + + #[tokio::test] + async fn over_typesafes_limits_the_pass_stops_and_the_next_call_waits_its_turn() { + let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await; + let signal = person(&ctx, "sig-cy", "slow-cy", &[("c", "fits")]).await; + let limited_before = STATS.jev_calls[3].load(Relaxed); + assert!(!pass(&ctx).await.unwrap(), "stops rather than going straight round"); + assert!(verdicts(&ctx, signal).await.is_empty()); + assert!(STATS.jev_calls[3].load(Relaxed) > limited_before, "counted as rate_limited"); + // Retry-After said a second: the next call to Jev, anyone's, waits for it. + let started = std::time::Instant::now(); + check_key(&ctx, "sig-cy", "good-cy").await.unwrap(); + assert!(started.elapsed() >= Duration::from_millis(900), "{:?}", started.elapsed()); + } + + #[tokio::test] + async fn a_request_typesafe_cannot_read_stops_the_pass_and_is_counted() { + let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await; + let signal = person(&ctx, "sig-dee", "malformed-dee", &[("d", "fits"), ("e", "fits")]).await; + let malformed_before = STATS.jev_calls[2].load(Relaxed); + assert!(!pass(&ctx).await.unwrap(), "every other item's would fail the same way (#257)"); + assert!(verdicts(&ctx, signal).await.is_empty()); + assert!(STATS.jev_calls[2].load(Relaxed) > malformed_before); + } + + #[tokio::test] + async fn a_key_is_checked_and_an_icon_is_one_jev_was_offered() { + let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await; + check_key(&ctx, "sig-eve", "good-eve").await.unwrap(); + assert!(refuses_key(&check_key(&ctx, "sig-eve", "refused-eve").await.unwrap_err())); + assert_eq!(STATS.billed("sig-eve", "own")[2], 100.0, "a check costs tokens too"); + + let offered = [("solid/robot".to_owned(), "robot".to_owned()), ("solid/leaf".to_owned(), "leaf".to_owned())]; + let words = ["ai".to_owned()]; + let icon = pick_icon(&ctx, "sig-eve", "good-eve", "AI", &words, None, &offered).await.unwrap(); + assert_eq!(icon, "solid/robot"); + assert!(pick_icon(&ctx, "sig-eve", "offlist-eve", "AI", &words, None, &offered).await.is_err(), "not offered"); + assert_eq!(STATS.billed("sig-eve", "server")[2], 200.0, "icons are on the server's key"); + } #[test] fn a_noul_of_a_half_or_more_is_a_fit() { diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 1bb87c3..3ae79bf 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1422,6 +1422,97 @@ mod tests { assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", "ipx_session=forged")], None).await.0, 401); } + #[tokio::test] + async fn a_signal_is_made_changed_listed_and_removed_by_its_owner_alone() { + let (app, ctx) = app(proxied()).await; + // As the proxy signs each in; the name is all that changes. + let call = |who: &'static str, m: &'static str, uri: String, b: Option| { + let app = app.clone(); + async move { ask(&app, m, &uri, CADDY, &[("Cf-Access-Authenticated-User-Email", who)], b).await } + }; + let (dee, sam) = ("dee@x.test", "sam@x.test"); + let json = |b: &str| serde_json::from_str::(b).unwrap(); + + let make = serde_json::json!({ "name": "Rust", "keywords": ["rust", " Rust ", "cargo"], "icon": "solid/gear" }); + let (s, _, b) = call(dee, "POST", "/api/signals".into(), Some(make)).await; + assert_eq!(s, 200, "{b}"); + let id = json(&b)["id"].as_i64().unwrap(); + for (bad, why) in [ + (serde_json::json!({ "name": "", "keywords": ["x"] }), "no name"), + (serde_json::json!({ "name": "X", "keywords": [] }), "no words"), + (serde_json::json!({ "name": "X", "keywords": ["x"], "icon": "solid/../x" }), "an icon that is not a name"), + ( + serde_json::json!({ "name": "X", "keywords": ["x"], "description": "d".repeat(501) }), + "a long description", + ), + ] { + assert_eq!(call(dee, "POST", "/api/signals".into(), Some(bad)).await.0, 400, "{why}"); + } + let (_, _, list) = call(dee, "GET", "/api/signals".into(), None).await; + let list = json(&list); + assert_eq!(list.as_array().unwrap().len(), 1); + assert_eq!(list[0]["keywords"], serde_json::json!(["rust", "cargo"]), "trimmed, once each"); + assert_eq!(list[0]["icon"], "solid/gear"); + + let change = + serde_json::json!({ "name": "Rust lang", "keywords": ["rustlang"], "description": "the language" }); + assert_eq!(call(dee, "PATCH", format!("/api/signals/{id}"), Some(change)).await.0, 204); + let row = + ctx.db.signal(ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap().id, id).await.unwrap().unwrap(); + assert_eq!( + (row.name.as_str(), row.keywords.as_slice(), row.description.as_deref()), + ("Rust lang", &["rustlang".to_owned()][..], Some("the language")) + ); + assert_eq!( + call(dee, "PATCH", format!("/api/signals/{id}"), Some(serde_json::json!({ "icon": null }))).await.0, + 204 + ); + assert!( + ctx.db.signal(row_owner(&ctx, "dee@x.test").await, id).await.unwrap().unwrap().icon.is_none(), + "cleared" + ); + + // What it picked out is listed under it. + let owner = row_owner(&ctx, "dee@x.test").await; + ctx.db + .exec_for_test(&format!( + "INSERT INTO subscriptions (user_id, feed_id) VALUES ({owner}, 'f'); + INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('f', 'g1', 'Rust 2.0', 1), ('f', 'g2', 'Gardening', 1);" + )) + .await + .unwrap(); + ctx.db.record_verdicts("f", "g1", &[(id, true)]).await.unwrap(); + ctx.db.record_verdicts("f", "g2", &[(id, false)]).await.unwrap(); + let (s, _, page) = call(dee, "GET", format!("/api/signals/{id}/entries"), None).await; + let page = json(&page); + assert_eq!((s, page["total"].as_i64()), (200, Some(1)), "{page}"); + assert_eq!(page["entries"][0]["guid"], "g1"); + + // Someone else's Signal is not there for them, in any way. + assert_eq!(call(sam, "GET", format!("/api/signals/{id}/entries"), None).await.0, 404); + assert_eq!( + call(sam, "PATCH", format!("/api/signals/{id}"), Some(serde_json::json!({ "name": "Mine" }))).await.0, + 404 + ); + assert_eq!(call(sam, "DELETE", format!("/api/signals/{id}"), None).await.0, 404); + let (_, _, theirs) = call(sam, "GET", "/api/signals".into(), None).await; + assert_eq!(json(&theirs), serde_json::json!([])); + + assert_eq!(call(dee, "DELETE", format!("/api/signals/{id}"), None).await.0, 204); + assert_eq!(call(dee, "DELETE", format!("/api/signals/{id}"), None).await.0, 404, "already gone"); + assert!( + ctx.db + .strings_for_test(&format!("SELECT guid FROM signal_verdicts WHERE signal_id = {id}")) + .await + .is_empty(), + "its verdicts with it" + ); + } + + async fn row_owner(ctx: &Ctx, name: &str) -> i64 { + ctx.db.user_by_name(name).await.unwrap().unwrap().id + } + #[tokio::test] async fn the_block_list_and_the_typesafe_key_are_checked_before_they_are_saved() { let (app, ctx) = app(proxied()).await; diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 5569ef6..8a13f8b 100644 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -91,7 +91,7 @@ if [ "${1:-}" = api ]; then # Not `set -e`'s to stop on: a failing test still ran what it ran, and the report says so; the # suite's own exit code is this script's at the end. suite=0 - IPX_API_COVERAGE=$LLVM_TARGET IPX_COVERAGE=1 LLVM_PROFILE_FILE="$LLVM_TARGET/ipx-browser-%p-%14m.profraw" \ + IPX_API_COVERAGE=$LLVM_TARGET IPX_COVERAGE=1 IPX_SIGNALS_TESTS=1 LLVM_PROFILE_FILE="$LLVM_TARGET/ipx-browser-%p-%14m.profraw" \ npx playwright test || suite=$? ls "$LLVM_TARGET"/ipx-browser-*.profraw >/dev/null 2>&1 \ || { echo "no profiles were written: did the servers stop on SIGTERM?" >&2; exit 1; } diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index 61a1dba..2848b3e 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -2383,6 +2383,32 @@ signalsTest('a Signal with no key to judge it links to where one is got and wher await page.request.delete(`/api/signals/${id}`); }); +signalsTest('a Signal is renamed from its own page and deleted from there', async ({ page }) => { + // #398: signalHead's edit and delete, which nothing clicked. + expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204); + const { id } = await (await page.request.post('/api/signals', { data: { name: 'Baking', keywords: ['bread'] } })).json(); + try { + await page.reload(); + await page.evaluate((id: number) => selectFeed(':signal:' + id), id); + await page.locator('#content [data-a="settings"]').click(); + await expect(page.locator('#sgname')).toHaveValue('Baking'); + await page.locator('#sgname').fill('Bread and cakes'); + await page.locator('#sgsave').click(); + await expect(page.locator('#feedlist .feed.child', { hasText: 'Bread and cakes' })).toBeVisible(); + const one = async () => (await (await page.request.get('/api/signals')).json() as Signal[]).find(s => s.id === id); + expect((await one())?.name).toBe('Bread and cakes'); + + page.once('dialog', d => d.accept()); + await page.locator('#content [data-a="rm"]').click(); + await expect(page.locator('.toast', { hasText: 'Deleted' })).toBeVisible(); + await expect(page.locator('#feedlist .feed.child', { hasText: 'Bread and cakes' })).toHaveCount(0); + expect(await one()).toBeUndefined(); + } finally { + await page.request.delete(`/api/signals/${id}`); + await page.request.patch('/api/me', { data: { typesafe_key: null } }); + } +}); + signalsTest('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => { // #271. The test daemon has no key of its own, so each person brings theirs. await page.request.patch('/api/me', { data: { typesafe_key: null } });