diff --git a/CHANGELOG.md b/CHANGELOG.md index 171f260d3e..9d98f16d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,15 @@ # Changelog +## Unreleased + +### Desktop and shared changes + +- feat(desktop): add in-app admin console for relay operators — NIP-98 client for deployment-wide reports and product feedback (`Settings → Admin console`) + ## v0.5.5 ### Desktop and shared changes -- feat: paste composer text without formatting ([#4801](https://github.com/block/buzz/pull/4801)) ([`25a9cf1be6d245fbd7373cb1160dbc790baf5bd5`](https://github.com/block/buzz/commit/25a9cf1be6d245fbd7373cb1160dbc790baf5bd5)) -- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4808](https://github.com/block/buzz/pull/4808)) ([`79c52166cfe6b6d36bdc7686f943595c74e2f578`](https://github.com/block/buzz/commit/79c52166cfe6b6d36bdc7686f943595c74e2f578)) -- chore(release): release Buzz Desktop version 0.5.5 ([#4800](https://github.com/block/buzz/pull/4800)) ([`a0ed13de14ee64dd90c32335790f7d3b4e94330d`](https://github.com/block/buzz/commit/a0ed13de14ee64dd90c32335790f7d3b4e94330d)) -- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) -- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) -- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) -- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7)) -- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4)) -- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48)) - fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453)) - fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2)) - fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a)) @@ -53,7 +50,7 @@ - fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701)) [Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5) - +>>>>>>> 9570237a1 (feat(desktop): add in-app admin console for relay operators) ## v0.5.4 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index a1fd2e919d..64064001b3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test \"src/**/*.jsdom-test.mjs\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/src-tauri/src/commands/admin/client.rs b/desktop/src-tauri/src/commands/admin/client.rs new file mode 100644 index 0000000000..21e805776a --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/client.rs @@ -0,0 +1,100 @@ +//! Dedicated no-redirect HTTP client for admin API requests. +//! +//! A separate client (not the app-wide `http_client`) ensures that: +//! - 3xx responses are surfaced as errors rather than followed — preventing +//! redirect-hop SSRF where a relay-issued redirect could forward the NIP-98 +//! `Authorization` header to an off-origin host. +//! - Timeouts are tuned for synchronous UI feedback rather than media downloads. + +use std::sync::OnceLock; + +/// Request timeout for admin API calls. +pub(crate) const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// The module-level singleton admin HTTP client. +/// +/// Built once via `OnceLock` — panics on build failure so there is no +/// silent fallback to a redirect-following client. +pub static ADMIN_CLIENT: OnceLock = OnceLock::new(); + +/// Initialise the admin client singleton. Must be called from `setup()` before +/// any admin command can be invoked. Subsequent calls are no-ops. +pub fn init_admin_client() { + ADMIN_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(2) + .redirect(reqwest::redirect::Policy::none()) + .timeout(ADMIN_TIMEOUT) + .build() + .expect( + "admin HTTP client must build with redirect::Policy::none(); \ + a redirect-following fallback would forward the NIP-98 \ + Authorization header across origins (redirect-hop SSRF)", + ) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The admin client must be buildable and must refuse to follow redirects. + /// This mirrors the `build_media_fetch_client_succeeds_with_no_redirect_policy` + /// test in `media_download.rs`. + #[test] + fn admin_client_builds_with_no_redirect_policy() { + init_admin_client(); + assert!(ADMIN_CLIENT.get().is_some()); + } + + /// A live test that the client does not follow a 302. + /// + /// Mirrors `media_fetch_client_does_not_follow_redirects` in + /// `media_download.rs`. Serves a 302 pointing at the metadata endpoint + /// and asserts exactly one connection was accepted. + #[tokio::test] + async fn admin_client_does_not_follow_redirects() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + init_admin_client(); + let client = ADMIN_CLIENT.get().expect("client initialised"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let connections = Arc::new(AtomicUsize::new(0)); + + let server_connections = Arc::clone(&connections); + let server = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + server_connections.fetch_add(1, Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + let resp = client + .get(format!("http://{addr}/api/admin/v1/reports")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .expect("request should complete without following the redirect"); + + assert_eq!(resp.status().as_u16(), 302); + server.join().unwrap(); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "exactly one request must be issued — redirect must not be followed", + ); + } +} diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs new file mode 100644 index 0000000000..f57fc2a236 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -0,0 +1,850 @@ +//! Desktop in-app admin surface — NIP-98 client for `/api/admin/v1`. +//! +//! Implements five Tauri commands that fetch JSON and binary content from the +//! relay's deployment-admin API using the app keypair as the NIP-98 signing +//! identity. A sixth command, `admin_probe`, discovers which authentication +//! mode the configured admin origin is running and whether the app identity +//! is authorized. +//! +//! # Security model +//! +//! The webview never supplies paths, methods, or full URLs. Every IPC command +//! accepts an `AdminOrigin` (scheme + host + optional port, validated on +//! construction) and typed query parameters; the final URL is built natively +//! from a closed route enum. The URL that is signed is byte-identical to the +//! URL that is fetched. +//! +//! A dedicated no-redirect reqwest client prevents redirect-hop SSRF — a relay +//! 3xx is returned verbatim and treated as an error so the NIP-98 header is +//! never forwarded across origins. +//! +//! Keys are acquired via `AppState::signing_keys()`, which returns `Err` when +//! the identity is in recovery mode (keyring locked or lost), ensuring the app +//! keypair can never sign admin events under an inaccessible identity. +//! +//! Response sizes are bounded by Content-Length preflight and a streaming byte +//! counter, mirroring the `media_download.rs` pattern. + +pub mod client; +pub(crate) mod origin; +pub(crate) mod routes; + +// ── Response size caps ──────────────────────────────────────────────────── + +/// Success-JSON cap: reports list returns up to 200 rows, each note field +/// can reach the 256 KiB event-content cap. Sized for the worst case. +const SUCCESS_JSON_CAP: u64 = 52_428_800; // 50 MiB + +/// Error-body cap: relay error responses are brief JSON envelopes. +const ERROR_BODY_CAP: u64 = 65_536; // 64 KiB + +/// Attachment preview cap. 10 MiB is generous for images and small documents +/// while protecting against accidental OOM. +const ATTACHMENT_CAP: u64 = 10_485_760; // 10 MiB + +// ── Typed probe result ──────────────────────────────────────────────────── + +/// Result of an `admin_probe` call. Each variant maps to a distinct UI state. +/// Tauri serialises this as `{ "state": "" }`. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum AdminProbeResult { + /// NIP-98 mode is active and the current app keypair is on the allowlist. + Nip98Authorized, + /// NIP-98 mode is active but the app keypair was rejected after a signed + /// attempt. Likely: pubkey not in `BUZZ_ADMIN_PUBKEYS`, clock skew, or + /// relay config mismatch. + Nip98Denied, + /// Bearer-token mode (`BUZZ_ADMIN_AUTH=token`). The desktop cannot mint a + /// bearer token; the operator must use the web console. + TokenMode, + /// Auth is disabled (`BUZZ_ADMIN_AUTH=disabled`). No credential needed. + Disabled, + /// The origin is reachable but the `/api/admin/v1` prefix is absent or + /// returns a non-admin response. + NotAdminApi, + /// Network/TLS error, DNS failure, or Cloudflare Access interception. + NetworkOrIntercepted, +} + +// ── Typed query struct ──────────────────────────────────────────────────── + +/// Query parameters accepted by `admin_list_reports`. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportsQuery { + pub community_id: Option, + pub status: Option, + pub report_type: Option, + pub target_kind: Option, + pub after: Option, + pub before: Option, + pub limit: Option, +} + +// ── Probe ───────────────────────────────────────────────────────────────── + +/// A boxed signing closure: given a URL, returns a `Nostr ` Authorization header. +type SignFn = Box Result + Send + Sync>; + +/// Probe an admin origin to determine the authentication mode and whether the +/// current app keypair is authorized. +/// +/// Algorithm: +/// 1. Send an unauthenticated GET to `/api/admin/v1/reports?limit=1`. +/// 2. Detect HTML/interception pages (Cloudflare Access, captive portals) +/// from Content-Type and final URL host → `NetworkOrIntercepted`. +/// 3. 200 + valid JSON list shape → `Disabled` (admin accessible without cred). +/// 4. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly +/// signed kind-27235. 200 + valid list shape → `Nip98Authorized`; +/// non-200 → `Nip98Denied`. +/// 5. 401 + `WWW-Authenticate: Bearer` → `TokenMode`. +/// 6. 403/404 or other non-401 → `NotAdminApi`. +/// 7. Network/redirect/TLS error → `NetworkOrIntercepted`. +#[tauri::command] +pub async fn admin_probe( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + use crate::relay::build_nip98_auth_header_for_keys; + + // Resolve signing keys before entering the inner probe. Recovery mode + // (locked/lost keyring) is surfaced here rather than inside the loop. + let sign: Option = match state.signing_keys() { + Ok(keys) => Some(Box::new(move |url: &str| { + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}")) + })), + Err(_) => None, + }; + + admin_probe_inner(&origin, sign).await +} + +/// Inner probe implementation with injectable signing. +/// +/// Accepts an optional signing closure so live-listener tests can drive the +/// full state machine — including the Nostr challenge/response path — without +/// requiring a real `AppState`. `None` simulates recovery mode (no key). +async fn admin_probe_inner( + origin: &str, + sign: Option Result>, +) -> Result { + let origin = origin::AdminOrigin::parse(origin)?; + let url = origin.route_url( + &routes::AdminRoute::ReportsList, + &routes::AdminQuery { + limit: Some(1), + ..Default::default() + }, + ); + + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + // Step 1: unauthenticated GET. + let resp = match http_client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + tracing::debug!(error = %e, "admin_probe: network error"); + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + }; + + if resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Step 2: detect HTML/interception before reading body or interpreting status. + if is_probe_response_intercepted(&resp) { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Step 3: success without auth → disabled mode (if body is a valid list). + if resp.status().is_success() { + let content_type = response_content_type(&resp); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await?; + return if looks_like_admin_list(&content_type, &bytes) { + Ok(AdminProbeResult::Disabled) + } else { + Ok(AdminProbeResult::NotAdminApi) + }; + } + + // Step 4–6: interpret 401. + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let www_auth = resp + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + if www_auth.starts_with("nostr") { + // NIP-98 mode: try signing. + let auth_header = match &sign { + Some(f) => f(&url)?, + None => return Ok(AdminProbeResult::Nip98Denied), + }; + let auth_resp = match http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(AdminProbeResult::NetworkOrIntercepted), + }; + + // Redirects on the authenticated retry are also interception. + if auth_resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Validate the Authorization header was accepted by checking for HTML. + if is_probe_response_intercepted(&auth_resp) { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + if auth_resp.status().is_success() { + // Validate the Nostr header shape was accepted (not just any 2xx). + let content_type = response_content_type(&auth_resp); + let bytes = read_bounded(auth_resp, SUCCESS_JSON_CAP).await?; + return if looks_like_admin_list(&content_type, &bytes) { + Ok(AdminProbeResult::Nip98Authorized) + } else { + // Endpoint exists but didn't return the expected list shape. + Ok(AdminProbeResult::NotAdminApi) + }; + } + return Ok(AdminProbeResult::Nip98Denied); + } + + if www_auth.starts_with("bearer") { + return Ok(AdminProbeResult::TokenMode); + } + + // Unknown 401 shape. + return Ok(AdminProbeResult::NotAdminApi); + } + + Ok(AdminProbeResult::NotAdminApi) +} + +/// Check the response Content-Type and final URL host for signs of +/// captive-portal or Cloudflare Access interception. +/// +/// Uses the same classification logic as `relay.rs::classify_intercepted_response`. +fn is_probe_response_intercepted(resp: &reqwest::Response) -> bool { + let host = resp.url().host_str().unwrap_or("").to_lowercase(); + let ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + + // Cloudflare Access redirects to its own domain. + if host == "cloudflareaccess.com" || host.ends_with(".cloudflareaccess.com") { + return true; + } + // Any HTML body from a non-relay host is a proxy/captive portal page. + if ct.contains("text/html") { + return true; + } + false +} + +/// Read a bounded response body (no auth check, just bytes). +async fn read_bounded(resp: reqwest::Response, cap: u64) -> Result, String> { + use futures_util::StreamExt; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(format!("probe response too large ({cl} bytes)")); + } + } + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("probe stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("probe response too large (cap {cap} bytes)")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +/// Returns true when `content_type` is JSON and `bytes` deserialises to a +/// JSON array matching the `/api/admin/v1/reports` shape. +/// +/// Rules: +/// - Content-Type must start with `application/json` (case-insensitive). +/// - Body must be a JSON array. +/// - Non-empty arrays must have every element deserialise against the +/// `AdminReport` wire contract (camelCase, `rename_all = "camelCase"`). +/// An empty array is valid — a fresh relay with no reports returns `[]`. +/// - Partial / garbage elements (`{"id":null}`, `7`, `"garbage"`) are rejected. +/// +/// This prevents unrelated endpoints that return JSON arrays from being +/// misclassified as the admin API. +fn looks_like_admin_list(content_type: &str, bytes: &[u8]) -> bool { + // Require JSON Content-Type. + if !content_type + .to_ascii_lowercase() + .starts_with("application/json") + { + return false; + } + // Body must be a JSON array. + let arr = match serde_json::from_slice::(bytes) { + Ok(serde_json::Value::Array(a)) => a, + _ => return false, + }; + // Empty array is valid (fresh relay with no reports). + if arr.is_empty() { + return true; + } + // Non-empty: every element must deserialise against the AdminReport probe DTO. + // The wire shape is camelCase (serde rename_all = "camelCase"). + arr.iter() + .all(|v| serde_json::from_value::(v.clone()).is_ok()) +} + +/// Full `AdminReport` wire contract used for probe validation. +/// +/// Mirrors the camelCase serialisation of `AdminReport` in +/// `crates/buzz-db/src/admin_moderation.rs:24-55` exactly — required fields +/// are typed strictly, optional fields use `Option` with real types. +/// This ensures that a response with `createdAt: null` or a malformed optional +/// field (e.g. `channelId: 7`) is rejected, not silently classified as the +/// admin API. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AdminReportProbeDto { + #[allow(dead_code)] + id: uuid::Uuid, + #[allow(dead_code)] + community_id: uuid::Uuid, + #[allow(dead_code)] + community_host: String, + #[allow(dead_code)] + report_event_id: String, + #[allow(dead_code)] + reporter_pubkey: String, + #[allow(dead_code)] + target_kind: String, + #[allow(dead_code)] + target: String, + #[allow(dead_code)] + channel_id: Option, + #[allow(dead_code)] + report_type: String, + #[allow(dead_code)] + note: Option, + #[allow(dead_code)] + status: String, + #[allow(dead_code)] + resolved_by: Option, + #[allow(dead_code)] + resolved_at: Option>, + #[allow(dead_code)] + action_id: Option, + #[allow(dead_code)] + created_at: chrono::DateTime, +} + +/// Extract the normalised Content-Type base value (strips parameters). +fn response_content_type(resp: &reqwest::Response) -> String { + resp.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase() +} + +// ── Five typed data commands ────────────────────────────────────────────── + +/// Fetch the reports list. +#[tauri::command] +pub async fn admin_list_reports( + origin: String, + query: AdminReportsQuery, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let q = routes::AdminQuery { + community_id: query.community_id, + status: query.status, + report_type: query.report_type, + target_kind: query.target_kind, + after: query.after, + before: query.before, + limit: query.limit, + }; + let url = origin.route_url(&routes::AdminRoute::ReportsList, &q); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a single report's detail. +#[tauri::command] +pub async fn admin_get_report( + origin: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportDetail { id }, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch the feedback list. +#[tauri::command] +pub async fn admin_list_feedback( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackList, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a single feedback entry's detail (including imeta attachment metadata). +#[tauri::command] +pub async fn admin_get_feedback( + origin: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "feedback id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackDetail { id }, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a feedback attachment by SHA-256 hash. +/// +/// The front-end MUST supply `expectedMime` and `expectedSize` from the +/// server-validated `imeta` fields returned by `admin_get_feedback`. The +/// command verifies the relay's `Content-Type` against `expectedMime` and +/// the actual byte count against `expectedSize`. Mismatch or over-cap yields +/// a stable typed error-code string. +/// +/// Returns `tauri::ipc::Response` so bytes cross IPC as a raw `ArrayBuffer`. +#[tauri::command] +pub async fn admin_fetch_feedback_attachment( + origin: String, + feedback_id: String, + sha256: String, + expected_mime: String, + expected_size: u64, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + use crate::relay::build_nip98_auth_header_for_keys; + + // Validate inputs before any network activity. + let feedback_id = uuid::Uuid::parse_str(&feedback_id) + .map_err(|_| "admin_attachment_invalid_feedback_id".to_string())?; + let sha256 = routes::AttachmentHash::parse(&sha256) + .map_err(|_| "admin_attachment_invalid_hash".to_string())?; + if expected_size == 0 { + return Err("admin_attachment_invalid_size".to_string()); + } + if expected_size > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + if expected_mime.is_empty() { + return Err("admin_attachment_invalid_mime".to_string()); + } + + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackAttachment { + id: feedback_id, + sha256, + }, + &routes::AdminQuery::default(), + ); + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, &url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let resp = http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + .map_err(|e| { + tracing::debug!(error = %e, "admin attachment fetch failed"); + "admin_attachment_network_error".to_string() + })?; + + // One retry on 401 with a fresh NIP-98 event. + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, &url, &[]) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, auth_header2) + .send() + .await + .map_err(|_| "admin_attachment_network_error".to_string())?; + return finish_attachment_response(resp2, &expected_mime, expected_size).await; + } + + finish_attachment_response(resp, &expected_mime, expected_size).await +} + +// ── Origin storage commands ─────────────────────────────────────────────── + +/// Core storage logic for `get_admin_origin`, parameterised by data directory +/// and resolved pubkey hex. No `tauri::State` — testable with `tempdir`. +/// +/// Reads the per-pubkey JSON file, reparses the stored origin through +/// `AdminOrigin::parse()`, and returns the canonical string. Returns `None` +/// when no file exists. On malformed/invalid content, removes the file and +/// returns `Err` so the caller can surface a visible setup error. +pub(crate) fn get_admin_origin_core( + data_dir: &std::path::Path, + pubkey_hex: &str, +) -> Result, String> { + let path = data_dir.join(format!("admin-console-origin-{pubkey_hex}.json")); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("failed to read admin console origin: {e}"))?; + let stored: StoredAdminOrigin = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + let remove_result = std::fs::remove_file(&path); + return Err(match remove_result { + Ok(()) => format!("stored admin console origin is invalid (removed): {e}"), + Err(re) => format!( + "stored admin console origin is invalid (quarantine failed — {re}): {e}" + ), + }); + } + }; + match origin::AdminOrigin::parse(&stored.origin) { + Ok(o) => Ok(Some(o.as_str().to_string())), + Err(e) => { + let remove_result = std::fs::remove_file(&path); + Err(match remove_result { + Ok(()) => format!("stored admin console origin is invalid (removed): {e}"), + Err(re) => format!( + "stored admin console origin is invalid (quarantine failed — {re}): {e}" + ), + }) + } + } +} + +/// Core storage logic for `set_admin_origin`, parameterised by data directory +/// and resolved pubkey hex. No `tauri::State` — testable with `tempdir`. +/// +/// Validates and persists `raw_origin`. Pass `None` to clear. Returns the +/// canonical origin string on success, or `None` on clear. +pub(crate) fn set_admin_origin_core( + data_dir: &std::path::Path, + pubkey_hex: &str, + raw_origin: Option, +) -> Result, String> { + use crate::managed_agents::storage::atomic_write_json_restricted; + let path = data_dir.join(format!("admin-console-origin-{pubkey_hex}.json")); + match raw_origin { + None => { + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| format!("failed to remove admin console origin: {e}"))?; + } + Ok(None) + } + Some(raw) => { + let canonical = origin::AdminOrigin::parse(&raw)?.as_str().to_string(); + let payload = serde_json::to_vec_pretty(&StoredAdminOrigin { + origin: canonical.clone(), + }) + .map_err(|e| format!("failed to serialise admin console origin: {e}"))?; + atomic_write_json_restricted(&path, &payload)?; + Ok(Some(canonical)) + } + } +} + +/// Return the persisted admin console origin for the active pubkey, or `None` +/// if none has been saved yet. +/// +/// `expected_pubkey` is checked against the active signing key before +/// reading. This is a defence-in-depth guard: if a delayed IPC call arrives +/// after the user has switched identities, the mismatch is caught here and the +/// read is rejected so stale-session data cannot surface in the new session. +/// +/// The stored value is reparsed through `AdminOrigin::parse()` on every read. +/// If the stored content is invalid, it is removed and an error returned so +/// the settings card shows a visible setup error rather than silently degrading. +#[tauri::command] +pub fn get_admin_origin( + expected_pubkey: Option, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; + // If the caller supplied an expected pubkey, reject when it no longer + // matches the active key — a delayed IPC from a prior session. + if let Some(ref expected) = expected_pubkey { + if *expected != pubkey { + return Err( + "admin origin read rejected: active identity changed since request was sent" + .to_string(), + ); + } + } + use tauri::Manager as _; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create app data dir: {e}"))?; + get_admin_origin_core(&dir, &pubkey) +} + +/// Validate and persist the admin console origin for the active pubkey. +/// +/// `expected_pubkey` guards against delayed IPC: if the active signing key no +/// longer matches `expected_pubkey`, the write is rejected to prevent a save +/// started under identity A from writing into identity B's storage namespace. +/// +/// Passes `raw_origin` through `AdminOrigin::parse` to normalise and validate +/// it before writing. Pass `None` to clear the stored origin. +#[tauri::command] +pub fn set_admin_origin( + raw_origin: Option, + expected_pubkey: Option, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; + if let Some(ref expected) = expected_pubkey { + if *expected != pubkey { + return Err( + "admin origin write rejected: active identity changed since request was sent" + .to_string(), + ); + } + } + use tauri::Manager as _; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create app data dir: {e}"))?; + set_admin_origin_core(&dir, &pubkey, raw_origin) +} + +/// On-disk shape for the persisted admin console origin. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredAdminOrigin { + origin: String, +} + +/// Validate that `hex` is exactly 64 lowercase hexadecimal characters. +/// +/// `nostr::Keys::public_key().to_hex()` always produces this form, but this +/// check serves as a defence-in-depth guard against future API changes or +/// unexpected fallbacks that could produce a non-canonical string and silently +/// corrupt the filename-based per-pubkey namespace. +fn validate_pubkey_hex(hex: String) -> Result { + if hex.len() == 64 && hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + Ok(hex) + } else { + Err("signing key produced an unexpected pubkey format; cannot scope storage".to_string()) + } +} + +// ── Internal helpers ────────────────────────────────────────────────────── + +/// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap. +async fn fetch_admin_json( + url: &str, + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + use crate::relay::build_nip98_auth_header_for_keys; + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let resp = http_client + .get(url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + // One retry on 401 with a fresh NIP-98 event (new nonce). + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = http_client + .get(url) + .header(reqwest::header::AUTHORIZATION, auth_header2) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + return read_admin_response(resp2, cap, ERROR_BODY_CAP).await; + } + + read_admin_response(resp, cap, ERROR_BODY_CAP).await +} + +/// Stream and validate an attachment response, enforcing Content-Type, size, +/// and the cap. +async fn finish_attachment_response( + resp: reqwest::Response, + expected_mime: &str, + expected_size: u64, +) -> Result { + use futures_util::StreamExt; + + if resp.status().is_redirection() { + return Err("admin_attachment_redirect".to_string()); + } + if !resp.status().is_success() { + return Err(format!( + "admin_attachment_relay_error_{}", + resp.status().as_u16() + )); + } + + // Verify Content-Type before reading the body. + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if content_type != expected_mime.trim().to_ascii_lowercase() { + return Err("admin_attachment_mime_mismatch".to_string()); + } + + // Content-Length preflight. + if let Some(cl) = resp.content_length() { + if cl > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + if cl != expected_size { + return Err("admin_attachment_size_mismatch".to_string()); + } + } + + // Stream with running byte counter. + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "admin_attachment_stream_error".to_string())?; + if bytes.len() as u64 + chunk.len() as u64 > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + bytes.extend_from_slice(&chunk); + } + + // Final size check. + if bytes.len() as u64 != expected_size { + return Err("admin_attachment_size_mismatch".to_string()); + } + + Ok(tauri::ipc::Response::new(bytes)) +} + +/// Read a response body up to `success_cap` bytes on 2xx, `error_cap` on +/// non-2xx. Redirects are treated as errors (the no-redirect client surfaced +/// them rather than following). +async fn read_admin_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, String> { + use futures_util::StreamExt; + + if resp.status().is_redirection() { + return Err(format!( + "admin API returned a {} redirect (not followed)", + resp.status() + )); + } + + let (is_success, cap) = if resp.status().is_success() { + (true, success_cap) + } else { + (false, error_cap) + }; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(format!( + "admin response too large ({cl} bytes, cap {cap} bytes)" + )); + } + } + + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("admin response stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("admin response too large (cap {cap} bytes)")); + } + bytes.extend_from_slice(&chunk); + } + + if !is_success { + let body = String::from_utf8_lossy(&bytes); + return Err(format!("admin API error: {body}")); + } + + Ok(bytes) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs new file mode 100644 index 0000000000..07634fb356 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -0,0 +1,851 @@ +//! Unit and integration tests for `commands/admin/mod.rs` (split to keep `mod.rs` +//! under the 1000-line file-size ratchet). +//! +//! Included via `#[path = "mod_tests.rs"] mod tests;` at the bottom of `mod.rs`, +//! so `use super::*` gives access to all items in that module. + +use super::*; +use crate::commands::admin::{origin::AdminOrigin, routes::AdminRoute}; +use std::sync::Arc; + +/// Type alias for the request inspector closure passed to `serve_sequence_inspect`. +type RequestInspector = std::sync::Arc; + +/// Parsed HTTP request data for transport-layer assertions. +#[derive(Debug)] +struct RequestRecord { + method: String, + path: String, + auth: Option, +} + +// ── AdminOrigin × routes integration ───────────────────────────────────── + +#[test] +fn reports_list_url_contains_api_prefix() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportsList, &routes::AdminQuery::default()); + assert!( + url.starts_with("https://admin.example.com/api/admin/v1/"), + "URL must include /api/admin/v1/ prefix: {url}" + ); +} + +#[test] +fn localhost_uses_http_prefix() { + let o = AdminOrigin::parse("http://localhost:3000").unwrap(); + let url = o.route_url(&AdminRoute::FeedbackList, &routes::AdminQuery::default()); + assert!(url.starts_with("http://localhost:3000/api/admin/v1/")); +} + +// ── Attachment command validation (calls production validators) ─────────── + +#[test] +fn attachment_hash_valid_lowercase_hex_accepted() { + let result = routes::AttachmentHash::parse(&"a".repeat(64)); + assert!(result.is_ok(), "64 lowercase hex chars must be accepted"); +} + +#[test] +fn attachment_hash_uppercase_rejected_by_production_validator() { + let result = routes::AttachmentHash::parse(&"A".repeat(64)); + assert!( + result.is_err(), + "uppercase hex must be rejected — relay returns 404 for uppercase hashes" + ); +} + +#[test] +fn attachment_hash_63_chars_rejected_by_production_validator() { + let result = routes::AttachmentHash::parse(&"a".repeat(63)); + assert!(result.is_err(), "63 chars must be rejected"); +} + +#[test] +fn feedback_id_malformed_uuid_rejected_by_production_validator() { + let result = uuid::Uuid::parse_str("not-a-uuid"); + assert!(result.is_err(), "non-UUID feedback id must be rejected"); +} + +#[test] +fn feedback_id_slash_injection_rejected() { + let result = uuid::Uuid::parse_str("../../../etc/passwd"); + assert!( + result.is_err(), + "path traversal in feedback id must be rejected" + ); +} + +#[test] +fn feedback_id_query_injection_rejected() { + let result = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001?x=y"); + assert!( + result.is_err(), + "query injection in feedback id must be rejected" + ); +} + +// ── Content-Type matching ───────────────────────────────────────────────── + +#[test] +fn content_type_matching_is_case_insensitive_and_strips_params() { + let raw = "Image/PNG; charset=binary"; + let normalised = raw.split(';').next().unwrap().trim().to_ascii_lowercase(); + assert_eq!(normalised, "image/png"); +} + +// ── looks_like_admin_list ───────────────────────────────────────────────── + +fn valid_admin_report_json(id: &str) -> String { + format!( + r#"{{ + "id": "{id}", + "communityId": "00000000-0000-0000-0000-000000000002", + "communityHost": "relay.example.com", + "reportEventId": "aabbcc", + "reporterPubkey": "ddeeff", + "targetKind": "message", + "target": "112233", + "channelId": null, + "reportType": "spam", + "note": null, + "status": "open", + "resolvedBy": null, + "resolvedAt": null, + "actionId": null, + "createdAt": "2024-01-01T00:00:00Z" + }}"# + ) +} + +#[test] +fn looks_like_admin_list_empty_array_with_json_ct() { + assert!(looks_like_admin_list("application/json", b"[]")); +} + +#[test] +fn looks_like_admin_list_valid_report_element() { + let body = format!( + "[{}]", + valid_admin_report_json("00000000-0000-0000-0000-000000000001") + ); + assert!( + looks_like_admin_list("application/json", body.as_bytes()), + "single valid AdminReport element must classify as admin list" + ); +} + +#[test] +fn looks_like_admin_list_rejects_id_null() { + // {"id":null} passes the old `contains_key("id")` check but must be rejected + // because `null` is not a valid UUID. + assert!(!looks_like_admin_list( + "application/json", + b"[{\"id\":null}]" + )); +} + +#[test] +fn looks_like_admin_list_rejects_created_at_null() { + // Full-shape element with `createdAt: null` must be rejected — the wire + // contract requires a real RFC-3339 timestamp for `createdAt`. + let body = r#"[{ + "id": "00000000-0000-0000-0000-000000000001", + "communityId": "00000000-0000-0000-0000-000000000002", + "communityHost": "relay.example.com", + "reportEventId": "aabbcc", + "reporterPubkey": "ddeeff", + "targetKind": "message", + "target": "112233", + "channelId": null, + "reportType": "spam", + "note": null, + "status": "open", + "resolvedBy": null, + "resolvedAt": null, + "actionId": null, + "createdAt": null + }]"#; + assert!( + !looks_like_admin_list("application/json", body.as_bytes()), + "full-shape element with createdAt: null must not classify as admin list" + ); +} + +#[test] +fn looks_like_admin_list_rejects_malformed_optional_field() { + // Full-shape element with a malformed optional UUID field (`channelId: 7`) + // must be rejected — the wire contract requires Option for channelId. + let body = r#"[{ + "id": "00000000-0000-0000-0000-000000000001", + "communityId": "00000000-0000-0000-0000-000000000002", + "communityHost": "relay.example.com", + "reportEventId": "aabbcc", + "reporterPubkey": "ddeeff", + "targetKind": "message", + "target": "112233", + "channelId": 7, + "reportType": "spam", + "note": null, + "status": "open", + "resolvedBy": null, + "resolvedAt": null, + "actionId": null, + "createdAt": "2024-01-01T00:00:00Z" + }]"#; + assert!( + !looks_like_admin_list("application/json", body.as_bytes()), + "full-shape element with channelId: 7 (not a UUID) must not classify as admin list" + ); +} + +#[test] +fn looks_like_admin_list_rejects_garbage_fixture() { + // Pinned fixture from the spec: [{"id":null}, 7, "garbage"] must be rejected. + assert!(!looks_like_admin_list( + "application/json", + b"[{\"id\":null}, 7, \"garbage\"]" + )); +} + +#[test] +fn looks_like_admin_list_rejects_primitive_array() { + assert!(!looks_like_admin_list("application/json", b"[1]")); + assert!(!looks_like_admin_list( + "application/json", + b"[\"unrelated\"]" + )); + assert!(!looks_like_admin_list( + "application/json", + b"[{\"notId\":true}]" + )); +} + +#[test] +fn looks_like_admin_list_rejects_non_json_content_type() { + assert!(!looks_like_admin_list("text/html", b"[]")); + assert!(!looks_like_admin_list("", b"[]")); + assert!(!looks_like_admin_list("text/plain", b"[]")); +} + +#[test] +fn looks_like_admin_list_rejects_non_array() { + assert!(!looks_like_admin_list("application/json", b"{}")); + assert!(!looks_like_admin_list("application/json", b"\"string\"")); + assert!(!looks_like_admin_list("application/json", b"null")); + assert!(!looks_like_admin_list( + "application/json", + b"captive portal" + )); + assert!(!looks_like_admin_list("application/json", b"not json")); +} + +// ── Storage core through production code ───────────────────────────────── +// +// All tests call `get_admin_origin_core` / `set_admin_origin_core` directly +// — the `pub(crate)` functions parameterised by data directory and pubkey +// hex. No `tauri::State` needed; each test uses a `tempdir` for isolation. + +#[test] +fn storage_round_trip_returns_canonical_origin() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + let origin = "https://admin.example.com"; + let canonical = set_admin_origin_core(dir.path(), &pubkey, Some(origin.to_string())) + .unwrap() + .unwrap(); + assert!( + canonical.starts_with("https://admin.example.com"), + "canonical origin must start with the input origin: {canonical}" + ); + let read_back = get_admin_origin_core(dir.path(), &pubkey).unwrap().unwrap(); + assert_eq!( + canonical, read_back, + "read-back must match the canonical form returned by set" + ); +} + +#[test] +fn storage_two_identities_are_isolated() { + let dir = tempfile::tempdir().unwrap(); + let pubkey_a = "a".repeat(64); + let pubkey_b = "b".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey_a, + Some("https://admin-a.example.com".to_string()), + ) + .unwrap(); + set_admin_origin_core( + dir.path(), + &pubkey_b, + Some("https://admin-b.example.com".to_string()), + ) + .unwrap(); + + let a = get_admin_origin_core(dir.path(), &pubkey_a) + .unwrap() + .unwrap(); + let b = get_admin_origin_core(dir.path(), &pubkey_b) + .unwrap() + .unwrap(); + assert!( + a.contains("admin-a"), + "pubkey_a must read its own origin: {a}" + ); + assert!( + b.contains("admin-b"), + "pubkey_b must read its own origin: {b}" + ); + // No cross-read: each key sees only its own value. + assert!( + !a.contains("admin-b"), + "pubkey_a must not read pubkey_b's origin" + ); + assert!( + !b.contains("admin-a"), + "pubkey_b must not read pubkey_a's origin" + ); +} + +#[test] +fn storage_malformed_json_is_quarantined_and_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "c".repeat(64); + // Write a corrupt file directly — bypassing set_admin_origin_core. + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}.json")); + std::fs::write(&path, b"not valid json").unwrap(); + assert!(path.exists(), "corrupt file must exist before read"); + + let result = get_admin_origin_core(dir.path(), &pubkey); + assert!( + result.is_err(), + "malformed JSON must return Err: {result:?}" + ); + // Quarantine: the file must have been removed. + assert!( + !path.exists(), + "quarantine failed: corrupt file must be removed after error" + ); +} + +#[test] +fn storage_forbidden_path_bearing_origin_is_quarantined_and_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "d".repeat(64); + // Write a file whose stored origin contains a path component — + // AdminOrigin::parse must reject it, triggering quarantine. + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}.json")); + let payload = serde_json::json!({ "origin": "https://admin.example.com/forbidden/path" }); + std::fs::write(&path, serde_json::to_vec(&payload).unwrap()).unwrap(); + assert!(path.exists(), "seeded file must exist before read"); + + let result = get_admin_origin_core(dir.path(), &pubkey); + assert!( + result.is_err(), + "origin with path must return Err on reparse: {result:?}" + ); + assert!( + !path.exists(), + "quarantine failed: forbidden-origin file must be removed after error" + ); +} + +#[test] +fn storage_clear_removes_file() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey, + Some("https://admin.example.com".to_string()), + ) + .unwrap(); + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}.json")); + assert!(path.exists(), "file must exist after set"); + + let result = set_admin_origin_core(dir.path(), &pubkey, None).unwrap(); + assert_eq!(result, None, "clear must return None"); + assert!(!path.exists(), "clear must remove the file"); +} + +#[test] +fn storage_no_file_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "f".repeat(64); + let result = get_admin_origin_core(dir.path(), &pubkey).unwrap(); + assert_eq!(result, None, "absent file must return None"); +} + +// ── validate_pubkey_hex ─────────────────────────────────────────────────── + +#[test] +fn pubkey_hex_valid_64_lowercase() { + assert!(validate_pubkey_hex("a".repeat(64)).is_ok()); +} + +#[test] +fn pubkey_hex_uppercase_rejected() { + assert!(validate_pubkey_hex("A".repeat(64)).is_err()); +} + +#[test] +fn pubkey_hex_empty_rejected() { + assert!(validate_pubkey_hex("".to_string()).is_err()); +} + +#[test] +fn pubkey_hex_63_chars_rejected() { + assert!(validate_pubkey_hex("a".repeat(63)).is_err()); +} + +// ── Live stub helpers ───────────────────────────────────────────────────── + +/// Build a fake Response using a live TCP listener. +async fn fake_response(status: u16, headers: &str, body: &str) -> reqwest::Response { + use std::io::{Read, Write}; + client::init_admin_client(); + let client = client::ADMIN_CLIENT.get().unwrap(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body_bytes = body.as_bytes().to_vec(); + let body_len = body_bytes.len(); + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Length: {body_len}\r\n{headers}Connection: close\r\n\r\n" + ); + let response_bytes = response.into_bytes(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let _ = stream.write_all(&response_bytes); + let _ = stream.write_all(&body_bytes); + let _ = stream.flush(); + } + }); + client + .get(format!("http://{addr}/api/admin/v1/reports")) + .send() + .await + .unwrap() +} + +/// Serve sequential HTTP responses from a background thread. +/// +/// For each request the listener reads the raw HTTP bytes, calls the +/// provided inspector closure with the raw request bytes and slot index, +/// then sends the pre-configured response. The inspector records request +/// details post-hoc for assertion after the probe completes. +async fn serve_sequence_inspect( + responses: Vec<(&'static str, &'static str, &'static str)>, + inspect: Option, +) -> std::net::SocketAddr { + use std::io::{Read, Write}; + client::init_admin_client(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for (idx, (status, headers, body)) in responses.into_iter().enumerate() { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + // Invoke the inspector with the raw request bytes. + if let Some(ref f) = inspect { + f(idx, &buf[..n]); + } + let body_bytes = body.as_bytes(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body_bytes.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body_bytes); + let _ = stream.flush(); + } + } + }); + addr +} + +/// Serve sequential responses without request inspection (backward compat). +async fn serve_sequence( + responses: Vec<(&'static str, &'static str, &'static str)>, +) -> std::net::SocketAddr { + serve_sequence_inspect(responses, None).await +} + +/// Serve a two-slot NIP-98 stub where the second response is gated on the +/// received Authorization header matching `expected_token`. +/// +/// Slot 0: always 401 Unauthorized + `WWW-Authenticate: Nostr` (triggers retry). +/// Slot 1: 200 OK with JSON body if the received Authorization header equals +/// `expected_token`; plain 401 (no Nostr challenge) otherwise — a mismatch +/// means the production header call was missing, so the probe returns +/// Nip98Denied and the caller's `Nip98Authorized` assertion fails. +/// +/// Both slots are recorded in the returned `Arc>>`. +async fn serve_gated_nip98( + expected_token: String, + authorized_body: &'static str, +) -> ( + std::net::SocketAddr, + Arc>>, +) { + use std::io::{Read, Write}; + client::init_admin_client(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let records: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let records_bg = Arc::clone(&records); + std::thread::spawn(move || { + for slot in 0..2usize { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let text = std::str::from_utf8(&buf[..n]).unwrap_or(""); + // Parse request line and Authorization header. + let first_line = text.lines().next().unwrap_or(""); + let mut parts = first_line.splitn(3, ' '); + let method = parts.next().unwrap_or("").to_string(); + let path = parts.next().unwrap_or("").to_string(); + let auth = text + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("authorization:")) + .map(|l| l[l.find(':').unwrap() + 1..].trim().to_string()); + records_bg.lock().unwrap().push(RequestRecord { + method, + path, + auth: auth.clone(), + }); + // Gate: slot 0 always challenges; slot 1 returns 200 only on + // header match, 401 (no challenge) otherwise. + let (status, headers, body): (&str, &str, &str) = if slot == 0 { + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", "") + } else if auth.as_deref() == Some(expected_token.as_str()) { + ( + "200 OK", + "Content-Type: application/json\r\n", + authorized_body, + ) + } else { + // Mismatch or absent header → plain 401 (no Nostr challenge). + // admin_probe_inner sees a non-Nostr 401 after the retry and + // returns Nip98Denied, causing the caller's Nip98Authorized + // assertion to fail — which is the intended mutation catch. + ("401 Unauthorized", "", "") + }; + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); + } + } + }); + (addr, records) +} + +// ── is_probe_response_intercepted ──────────────────────────────────────── + +#[tokio::test] +async fn probe_html_200_classified_as_intercepted() { + let resp = fake_response( + 200, + "Content-Type: text/html; charset=utf-8\r\n", + "Sign in", + ) + .await; + assert!(is_probe_response_intercepted(&resp)); +} + +#[tokio::test] +async fn probe_json_200_not_classified_as_intercepted() { + let resp = fake_response(200, "Content-Type: application/json\r\n", "[]").await; + assert!(!is_probe_response_intercepted(&resp)); +} + +#[tokio::test] +async fn probe_json_200_with_valid_report_looks_like_admin_list() { + let body = format!( + "[{}]", + valid_admin_report_json("00000000-0000-0000-0000-000000000001") + ); + let resp = fake_response(200, "Content-Type: application/json\r\n", &body).await; + assert!(!is_probe_response_intercepted(&resp)); + let ct = response_content_type(&resp); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); + assert!(looks_like_admin_list(&ct, &bytes)); +} + +#[tokio::test] +async fn probe_json_200_bare_array_of_garbage_not_admin_api() { + let resp = fake_response(200, "Content-Type: application/json\r\n", "[1,2,3]").await; + let ct = response_content_type(&resp); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); + assert!(!looks_like_admin_list(&ct, &bytes)); +} + +// ── admin_probe_inner end-to-end state machine ──────────────────────────── + +#[tokio::test] +async fn probe_inner_html_200_is_network_or_intercepted() { + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: text/html\r\n", + "sign in", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NetworkOrIntercepted)); +} + +#[tokio::test] +async fn probe_inner_malformed_json_200_is_not_admin_api() { + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + "not valid json", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +#[tokio::test] +async fn probe_inner_json_empty_array_200_is_disabled() { + let addr = serve_sequence(vec![("200 OK", "Content-Type: application/json\r\n", "[]")]).await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Disabled)); +} + +#[tokio::test] +async fn probe_inner_bare_array_of_garbage_is_not_admin_api() { + // Non-empty arrays without valid AdminReport elements must not classify as admin API. + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + "[1,2,3]", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +#[tokio::test] +async fn probe_inner_garbage_fixture_id_null_and_primitives_is_not_admin_api() { + // Pinned fixture from the spec: must be rejected. + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + "[{\"id\":null}, 7, \"garbage\"]", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!( + matches!(result, AdminProbeResult::NotAdminApi), + "garbage fixture must be NotAdminApi, got {result:?}" + ); +} + +#[tokio::test] +async fn probe_inner_persistent_401_is_nip98_denied() { + let addr = serve_sequence(vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ("401 Unauthorized", "", ""), + ]) + .await; + let sign = |_url: &str| -> Result { Ok("Nostr dGVzdA==".to_string()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +#[tokio::test] +async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_auth_header() { + // Verifies: + // 1. probe state machine produces Nip98Authorized on a Nostr 401→200 sequence. + // 2. The second request carries an Authorization header equal to the signing + // closure's token — tested by the gated stub: slot 1 returns 200 only + // when the received Authorization header matches the expected token; any + // mismatch or absent header returns a plain 401, making the state machine + // return Nip98Denied and failing the Nip98Authorized assertion. + // 3. The first request carries no Authorization header. + // 4. Deleting the `.header(AUTHORIZATION, …)` production line causes the + // stub to receive no header on slot 1, return 401, and the test fails. + + let expected_token = "Nostr dGVzdA==".to_string(); + let expected_token_for_sign = expected_token.clone(); + + let valid_body = format!( + "[{}]", + valid_admin_report_json("00000000-0000-0000-0000-000000000003") + ); + let valid_body_static: &'static str = Box::leak(valid_body.into_boxed_str()); + + // serve_gated_nip98: slot 0 always challenges; slot 1 checks the Authorization + // header and returns 200 on match, 401 on mismatch/absent. + let (addr, records) = serve_gated_nip98(expected_token, valid_body_static).await; + + let sign = move |_url: &str| -> Result { Ok(expected_token_for_sign.clone()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + + assert!( + matches!(result, AdminProbeResult::Nip98Authorized), + "expected Nip98Authorized, got {result:?}" + ); + + let records = records.lock().unwrap(); + assert_eq!(records.len(), 2, "exactly two requests must have been made"); + + // Request 0: unauthenticated GET — no Authorization header. + assert_eq!( + records[0].method, "GET", + "slot-0 must be GET; got {:?}", + records[0].method + ); + assert!( + records[0].path.contains("/api/admin/v1/reports"), + "slot-0 must target the reports endpoint; got {:?}", + records[0].path + ); + assert!( + records[0].auth.is_none(), + "slot-0 must carry no Authorization; got {:?}", + records[0].auth + ); + + // Request 1: authenticated retry — Authorization must equal the signing token. + // The stub already enforced this (returned 200 only on match), so this + // post-hoc assertion documents the observed value for auditability. + assert_eq!( + records[1].method, "GET", + "slot-1 must be GET; got {:?}", + records[1].method + ); + assert!( + records[1].path.contains("/api/admin/v1/reports"), + "slot-1 must target the reports endpoint; got {:?}", + records[1].path + ); + assert_eq!( + records[1].auth.as_deref(), + Some("Nostr dGVzdA=="), + "slot-1 Authorization must equal the signing closure token" + ); +} + +#[tokio::test] +async fn probe_inner_missing_auth_header_fails_to_authorize() { + // Verifies the no-sign path: when no signing closure is provided and the + // server issues a Nostr challenge, admin_probe_inner returns Nip98Denied. + // The production code only calls `sign(url)?` when a signing closure is + // Some; passing None causes the signing step to be skipped entirely, so + // no Authorization header is attached and the probe returns Nip98Denied + // without making a second request. + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Nostr\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +#[tokio::test] +async fn probe_inner_authenticated_302_is_network_or_intercepted() { + let addr = serve_sequence(vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ( + "302 Found", + "Location: https://cloudflareaccess.com/\r\n", + "", + ), + ]) + .await; + let sign = |_url: &str| -> Result { Ok("Nostr dGVzdA==".to_string()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + assert!( + matches!(result, AdminProbeResult::NetworkOrIntercepted), + "authenticated 302 must be NetworkOrIntercepted, got {result:?}" + ); +} + +#[tokio::test] +async fn probe_inner_bearer_401_is_token_mode() { + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Bearer realm=\"admin\"\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::TokenMode)); +} + +#[tokio::test] +async fn probe_inner_no_sign_on_nostr_challenge_is_nip98_denied() { + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Nostr\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} diff --git a/desktop/src-tauri/src/commands/admin/origin.rs b/desktop/src-tauri/src/commands/admin/origin.rs new file mode 100644 index 0000000000..643e93097c --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/origin.rs @@ -0,0 +1,271 @@ +//! `AdminOrigin` — a validated canonical admin console URL origin. +//! +//! An `AdminOrigin` holds exactly `scheme://host[:port]` and nothing else. +//! The webview supplies a raw URL string; this type validates and normalises +//! it before any downstream code can use it to construct request URLs. +//! +//! # Accepted inputs +//! - `https://host` → `https://host` +//! - `https://host:8443` → `https://host:8443` +//! - `http://localhost` → `http://localhost` +//! - `http://localhost:3000` → `http://localhost:3000` +//! - `http://127.0.0.1` → `http://127.0.0.1` +//! - `http://[::1]` → `http://[::1]` +//! +//! # Rejected inputs +//! - Any URL with `http://` to a non-loopback host +//! - Any URL with credentials (`user:pass@`) +//! - Any URL with a non-root path (`/admin`, `/api`) +//! - Any URL with a query string (`?foo=bar`) +//! - Any URL with a fragment (`#section`) +//! - Unknown or unsupported schemes (`ftp://`, `ws://`) + +use super::routes::{AdminQuery, AdminRoute}; + +/// A validated canonical admin console origin: `scheme://host[:port]`. +/// +/// Constructed only through `AdminOrigin::parse`; the inner string is +/// guaranteed to be a valid canonical origin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminOrigin(String); + +impl AdminOrigin { + /// Parse and validate an operator-supplied URL into a canonical origin. + /// + /// Strips path, query, and fragment. Returns `Err` with a human-readable + /// message for any disallowed form. + pub fn parse(raw: &str) -> Result { + let parsed = + url::Url::parse(raw).map_err(|_| format!("invalid admin console URL: {raw:?}"))?; + + // Reject credentials. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("admin console URL must not contain credentials".to_string()); + } + + // Reject non-root path, query, and fragment. + let path = parsed.path(); + if path != "/" && !path.is_empty() { + return Err(format!( + "admin console URL must be an origin only (no path); got {path:?}" + )); + } + if parsed.query().is_some() { + return Err("admin console URL must not contain a query string".to_string()); + } + if parsed.fragment().is_some() { + return Err("admin console URL must not contain a fragment".to_string()); + } + + let host = parsed + .host_str() + .ok_or_else(|| "admin console URL has no host".to_string())?; + + let is_loopback = is_loopback_host(host); + + match parsed.scheme() { + "https" => { + // https is allowed for any host, including loopback (dev with TLS). + } + "http" => { + if !is_loopback { + return Err(format!( + "admin console URL must use HTTPS for non-loopback host {host:?}" + )); + } + } + other => { + return Err(format!( + "admin console URL scheme must be https (or http for loopback); got {other:?}" + )); + } + } + + // Build the canonical origin: scheme + "://" + host + optional :port. + let canonical = match parsed.port() { + Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port), + None => format!("{}://{}", parsed.scheme(), host), + }; + + Ok(AdminOrigin(canonical)) + } + + /// The canonical origin string, e.g. `https://admin.example.com`. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Build the full request URL for `route` with `query`. + pub fn route_url(&self, route: &AdminRoute, query: &AdminQuery) -> String { + let path = route.path(); + let qs = query.to_query_string(); + if qs.is_empty() { + format!("{}/api/admin/v1{path}", self.0) + } else { + format!("{}/api/admin/v1{path}?{qs}", self.0) + } + } +} + +/// Returns true when `host` is a loopback address (`localhost`, `127.x.x.x`, +/// `[::1]`). This mirrors `media_download.rs`'s localhost carve-out. +fn is_loopback_host(host: &str) -> bool { + host == "localhost" + || host == "[::1]" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Valid inputs ────────────────────────────────────────────────────────── + + #[test] + fn https_host_accepted() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com"); + } + + #[test] + fn https_host_port_accepted() { + let o = AdminOrigin::parse("https://admin.example.com:8443").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com:8443"); + } + + #[test] + fn https_trailing_slash_stripped() { + // url::Url always parses "/" as the path for scheme+host-only URLs. + let o = AdminOrigin::parse("https://admin.example.com/").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com"); + } + + #[test] + fn http_localhost_accepted() { + let o = AdminOrigin::parse("http://localhost").unwrap(); + assert_eq!(o.as_str(), "http://localhost"); + } + + #[test] + fn http_localhost_port_accepted() { + let o = AdminOrigin::parse("http://localhost:3000").unwrap(); + assert_eq!(o.as_str(), "http://localhost:3000"); + } + + #[test] + fn http_127_accepted() { + let o = AdminOrigin::parse("http://127.0.0.1").unwrap(); + assert_eq!(o.as_str(), "http://127.0.0.1"); + } + + #[test] + fn http_ipv6_loopback_accepted() { + let o = AdminOrigin::parse("http://[::1]:3000").unwrap(); + assert_eq!(o.as_str(), "http://[::1]:3000"); + } + + // ── Invalid inputs ──────────────────────────────────────────────────────── + + #[test] + fn http_non_loopback_rejected() { + assert!(AdminOrigin::parse("http://admin.example.com").is_err()); + } + + #[test] + fn ftp_scheme_rejected() { + assert!(AdminOrigin::parse("ftp://admin.example.com").is_err()); + } + + #[test] + fn credentials_rejected() { + assert!(AdminOrigin::parse("https://user:pass@admin.example.com").is_err()); + } + + #[test] + fn path_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com/api").is_err()); + } + + #[test] + fn query_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com?foo=bar").is_err()); + } + + #[test] + fn fragment_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com#section").is_err()); + } + + #[test] + fn garbage_rejected() { + assert!(AdminOrigin::parse("not a url").is_err()); + } + + // ── route_url builds correct URLs ───────────────────────────────────────── + + #[test] + fn route_url_reports_list_no_query() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportsList, &AdminQuery::default()); + assert_eq!(url, "https://admin.example.com/api/admin/v1/reports"); + } + + #[test] + fn route_url_report_detail() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportDetail { id }, &AdminQuery::default()); + assert_eq!( + url, + "https://admin.example.com/api/admin/v1/reports/00000000-0000-0000-0000-000000000001" + ); + } + + #[test] + fn route_url_feedback_attachment() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(); + let sha256 = + crate::commands::admin::routes::AttachmentHash::parse(&"ab".repeat(32)).unwrap(); + let url = o.route_url( + &AdminRoute::FeedbackAttachment { id, sha256 }, + &AdminQuery::default(), + ); + assert!(url.contains("/api/admin/v1/feedback/")); + assert!(url.contains("/attachments/")); + } + + // ── Host case pin test ──────────────────────────────────────────────────── + // + // The `url` crate (per the URL Standard) lowercases ASCII hostnames during + // parsing. `AdminOrigin` preserves whatever the URL Standard produces — + // which for ASCII hostnames is always lowercase. This matches the relay's + // requirement that the admin console URL's host equals `BUZZ_ADMIN_HOST` + // byte-for-byte: since the URL parser always lowercases, operators must + // configure `BUZZ_ADMIN_HOST` in lowercase as well. + // + // A relay-side normalization chore (separate PR) would make `BUZZ_ADMIN_HOST` + // lowercase on startup, eliminating the footgun entirely. + #[test] + fn host_case_preserved_as_supplied() { + // Lowercase input stays lowercase. + let lower = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!(lower.as_str(), "https://admin.example.com"); + + // The URL Standard normalises ASCII hostnames to lowercase — so "Admin.Example.Com" + // becomes "admin.example.com" after parsing. Both inputs produce the same + // canonical origin. Operators must therefore use lowercase in BUZZ_ADMIN_HOST. + let from_mixed = AdminOrigin::parse("https://Admin.Example.Com").unwrap(); + assert_eq!( + from_mixed.as_str(), + "https://admin.example.com", + "url::Url lowercases ASCII hostnames; canonical origin is always lowercase" + ); + + // Consequently the two parsed origins ARE equal — they produce identical + // NIP-98 u-tag values and both match a lowercase BUZZ_ADMIN_HOST. + assert_eq!(lower.as_str(), from_mixed.as_str()); + } +} diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs new file mode 100644 index 0000000000..7504ec463c --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -0,0 +1,263 @@ +//! Closed route enum and typed query parameters for the admin API. +//! +//! No IPC surface accepts an arbitrary path; every URL is constructed here +//! from a typed route and typed query parameters. IDs are carried as `Uuid` +//! values so path injection is structurally impossible; the attachment hash is +//! validated to match the relay's exact lowercase-hex-only grammar before a +//! route is constructed. + +/// A validated lowercase 64-hex SHA-256 hash suitable for use as an attachment +/// path segment. Constructed only through [`AttachmentHash::parse`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachmentHash(String); + +impl AttachmentHash { + /// Parse `raw` as a lowercase 64-hex SHA-256. Returns `Err` for any input + /// that isn't exactly 64 lowercase hex digits, including uppercase A-F (the + /// relay stores lowercase and returns 404 on uppercase). + pub fn parse(raw: &str) -> Result { + if raw.len() != 64 { + return Err(format!( + "attachment hash must be exactly 64 hex characters; got {} characters", + raw.len() + )); + } + if !raw.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err("attachment hash must be lowercase hex only (0-9, a-f); \ + uppercase is rejected — the relay stores lowercase and returns 404 otherwise" + .to_string()); + } + Ok(AttachmentHash(raw.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// The five read routes exposed by `/api/admin/v1`. +/// +/// IDs are typed `Uuid` — path injection via slash, `..`, `?`, `#`, or +/// percent-escapes is structurally impossible. The attachment hash is an +/// `AttachmentHash`, enforcing exact lowercase-hex grammar. +#[derive(Debug)] +pub enum AdminRoute { + ReportsList, + ReportDetail { + id: uuid::Uuid, + }, + FeedbackList, + FeedbackDetail { + id: uuid::Uuid, + }, + FeedbackAttachment { + id: uuid::Uuid, + sha256: AttachmentHash, + }, +} + +impl AdminRoute { + /// Return the URL path component (not including the `/api/admin/v1` prefix). + pub fn path(&self) -> String { + match self { + AdminRoute::ReportsList => "/reports".to_string(), + AdminRoute::ReportDetail { id } => format!("/reports/{id}"), + AdminRoute::FeedbackList => "/feedback".to_string(), + AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), + AdminRoute::FeedbackAttachment { id, sha256 } => { + format!("/feedback/{id}/attachments/{}", sha256.as_str()) + } + } + } +} + +/// Optional query parameters for the reports-list endpoint. +/// +/// All fields are `Option` so the struct can be constructed with only +/// the fields the caller cares about; `to_query_string` omits `None` fields. +#[derive(Debug, Default)] +pub struct AdminQuery { + pub community_id: Option, + pub status: Option, + pub report_type: Option, + pub target_kind: Option, + pub after: Option, + pub before: Option, + pub limit: Option, +} + +impl AdminQuery { + /// Serialise to a URL query string (no leading `?`). Returns an empty + /// string when all fields are `None`. + pub fn to_query_string(&self) -> String { + let mut parts: Vec = Vec::new(); + if let Some(v) = &self.community_id { + parts.push(format!("communityId={}", urlencoded(v))); + } + if let Some(v) = &self.status { + parts.push(format!("status={}", urlencoded(v))); + } + if let Some(v) = &self.report_type { + parts.push(format!("reportType={}", urlencoded(v))); + } + if let Some(v) = &self.target_kind { + parts.push(format!("targetKind={}", urlencoded(v))); + } + if let Some(v) = &self.after { + parts.push(format!("after={}", urlencoded(v))); + } + if let Some(v) = &self.before { + parts.push(format!("before={}", urlencoded(v))); + } + if let Some(v) = &self.limit { + parts.push(format!("limit={v}")); + } + parts.join("&") + } +} + +/// Percent-encode a query parameter value, matching `url::form_urlencoded`. +fn urlencoded(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── AttachmentHash validation ───────────────────────────────────────────── + + #[test] + fn attachment_hash_valid_lowercase_hex() { + let h = AttachmentHash::parse(&"a".repeat(64)).unwrap(); + assert_eq!(h.as_str(), "a".repeat(64)); + } + + #[test] + fn attachment_hash_rejects_too_short() { + assert!(AttachmentHash::parse(&"a".repeat(63)).is_err()); + } + + #[test] + fn attachment_hash_rejects_too_long() { + assert!(AttachmentHash::parse(&"a".repeat(65)).is_err()); + } + + #[test] + fn attachment_hash_rejects_uppercase() { + // Uppercase passes is_ascii_hexdigit() but the relay returns 404 for it. + // AttachmentHash::parse must reject uppercase. + assert!(AttachmentHash::parse(&"A".repeat(64)).is_err()); + let mixed = format!("{}A{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&mixed).is_err()); + } + + #[test] + fn attachment_hash_rejects_non_hex_chars() { + // 'g' is not a hex digit. + assert!(AttachmentHash::parse(&"g".repeat(64)).is_err()); + } + + #[test] + fn attachment_hash_rejects_slash() { + let s = format!("{}/{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_dot_dot() { + let s = format!("{}..{}", "a".repeat(31), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_percent_escape() { + // URL-encoded slash would be %2F — 3 chars, must fail length check too. + assert!(AttachmentHash::parse("%2F").is_err()); + // But also reject any % in a 64-char input. + let s = format!("{}%2{}", "a".repeat(31), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_query_fragment() { + let s = format!("{}?{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + let s2 = format!("{}#{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s2).is_err()); + } + + // ── AdminRoute::path ───────────────────────────────────────────────────── + + #[test] + fn reports_list_path() { + assert_eq!(AdminRoute::ReportsList.path(), "/reports"); + } + + #[test] + fn report_detail_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + assert_eq!( + AdminRoute::ReportDetail { id }.path(), + "/reports/00000000-0000-0000-0000-000000000001" + ); + } + + #[test] + fn feedback_attachment_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + let hash = AttachmentHash::parse(&"ab".repeat(32)).unwrap(); + let path = AdminRoute::FeedbackAttachment { + id, + sha256: hash.clone(), + } + .path(); + assert_eq!( + path, + format!( + "/feedback/00000000-0000-0000-0000-000000000002/attachments/{}", + hash.as_str() + ) + ); + } + + // ── AdminQuery ─────────────────────────────────────────────────────────── + + #[test] + fn query_empty_produces_no_string() { + assert_eq!(AdminQuery::default().to_query_string(), ""); + } + + #[test] + fn query_limit_only() { + let q = AdminQuery { + limit: Some(50), + ..Default::default() + }; + assert_eq!(q.to_query_string(), "limit=50"); + } + + #[test] + fn query_multiple_params() { + let q = AdminQuery { + status: Some("open".to_string()), + limit: Some(100), + ..Default::default() + }; + let qs = q.to_query_string(); + assert!(qs.contains("status=open"), "expected status in {qs}"); + assert!(qs.contains("limit=100"), "expected limit in {qs}"); + } + + #[test] + fn query_value_is_percent_encoded() { + let q = AdminQuery { + status: Some("open&active".to_string()), + ..Default::default() + }; + let qs = q.to_query_string(); + // & in value must be encoded so it doesn't split the query. + assert!(!qs.contains("status=open&active"), "bare & leaked: {qs}"); + assert!(qs.contains("status="), "status key missing: {qs}"); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..e0a99a5a68 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; mod agent_auth; mod agent_config; mod agent_discovery; @@ -63,6 +64,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use admin::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a7c191c43b..91e9c3068d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -311,6 +311,10 @@ pub fn run() { #[cfg(target_os = "macos")] tray_menu::init(&app_handle)?; + // Initialise the no-redirect admin HTTP client singleton before any + // admin command can be invoked. Must run before setup completes. + commands::admin::client::init_admin_client(); + // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe // completes atomically on crash recovery. @@ -901,6 +905,15 @@ pub fn run() { tray_menu::take_tray_actions, #[cfg(target_os = "macos")] tray_menu::update_tray_agent_activity, + // ── Desktop admin surface ──────────────────────────────────────── + admin_probe, + admin_list_reports, + admin_get_report, + admin_list_feedback, + admin_get_feedback, + admin_fetch_feedback_attachment, + get_admin_origin, + set_admin_origin, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx new file mode 100644 index 0000000000..e55d7659c4 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -0,0 +1,649 @@ +/** + * Main admin console panel — renders when probe state is `nip98Authorized`. + * + * Shows two tabs: Reports (deployment-wide moderation reports) and Feedback + * (product feedback with optional image attachments). + * + * All query/UI state is keyed by `(pubkey, origin)`. In-flight native requests + * are fenced by an effect-local `active` flag that is set to `false` in the + * effect cleanup, ensuring stale results are discarded on arrival. + * + * Tauri invoke is not cancellable at the native layer, but the active-flag + * pattern ensures stale results never update visible state or create + * unreachable blob URLs. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + AlertCircle, + ChevronLeft, + Download, + LoaderCircle, + MessageSquare, + ShieldAlert, +} from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { + fetchAdminAttachmentBlobUrl, + getAdminFeedback, + getAdminReport, + listAdminFeedback, + listAdminReports, + type AdminAttachmentErrorCode, +} from "./api"; + +// ── Generic async state ─────────────────────────────────────────────────── + +type AsyncState = + | { status: "idle" } + | { status: "loading" } + | { status: "ok"; data: T } + | { status: "error"; message: string }; + +/** + * Async load hook with effect-local active-flag cancellation. + * + * Each effect invocation sets `active = true` and flips it to `false` in the + * cleanup function. Completions check `active` before calling setState, so a + * result that arrives after the deps changed (or the component unmounted) is + * silently discarded. + * + * `load` is stored in a ref so it is not a dependency of the effect — callers + * create it inline and `deps` + `generation` are the explicit trigger list. + */ +function useAsyncLoad( + load: () => Promise, + deps: unknown[], + generation: number, +): AsyncState { + const [state, setState] = useState>({ status: "idle" }); + const loadRef = useRef(load); + loadRef.current = load; + + // biome-ignore lint/correctness/useExhaustiveDependencies: loadRef is a stable ref; deps and generation are the intentional trigger set + useEffect(() => { + let active = true; + setState({ status: "loading" }); + loadRef.current().then( + (data) => { + if (!active) return; + setState({ status: "ok", data }); + }, + (e: unknown) => { + if (!active) return; + setState({ + status: "error", + message: e instanceof Error ? e.message : String(e), + }); + }, + ); + return () => { + active = false; + }; + }, [...deps, generation]); + + return state; +} + +// ── imeta attachment parsing ────────────────────────────────────────────── + +/** + * Validated attachment metadata parsed from a feedback detail's `tags` field. + * The relay serialises `AdminFeedback` with `serde(rename_all = "camelCase")`, + * so the wire shape is `{ ..., tags: string[][] }`. + */ +type AttachmentMeta = { + /** Lowercase 64-hex SHA-256 as stored/returned by the relay. */ + sha256: string; + /** MIME type from the `m` imeta field. */ + mime: string; + /** Byte size from the `size` imeta field. */ + size: number; +}; + +/** + * Parse imeta attachment metadata from the relay's `tags: string[][]` wire + * format. Matches the reference SPA implementation in `admin-web/src/App.tsx`. + * + * Each `imeta` tag looks like: + * `["imeta", "url https://...", "m image/png", "x ", "size 12345"]` + * Each entry after `"imeta"` is a singleton `"key value"` string. + * + * Rejected: missing x/m/size, non-lowercase-hex x, non-positive size. + */ +export function parseImetaAttachments(tags: unknown): AttachmentMeta[] { + if (!Array.isArray(tags)) return []; + const result: AttachmentMeta[] = []; + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== "imeta") continue; + const values = new Map(); + for (const entry of (tag as string[]).slice(1)) { + const sep = typeof entry === "string" ? entry.indexOf(" ") : -1; + if (sep > 0) { + values.set(entry.slice(0, sep), entry.slice(sep + 1)); + } + } + const sha256 = values.get("x") ?? ""; + const mime = values.get("m") ?? ""; + const rawSize = values.get("size") ?? ""; + const size = Number(rawSize); + // Require exactly 64 lowercase hex chars for the hash (relay stores lowercase; + // uppercase returns 404). Require a non-empty MIME type and a positive size. + if ( + sha256.length !== 64 || + !/^[0-9a-f]{64}$/.test(sha256) || + !mime || + !Number.isFinite(size) || + size <= 0 + ) { + continue; + } + result.push({ sha256, mime, size }); + } + return result; +} + +// ── Reports tab ─────────────────────────────────────────────────────────── + +function ReportsTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + + const listState = useAsyncLoad( + () => listAdminReports(origin), + [origin, pubkey], + generation, + ); + + if (selectedId) { + return ( + setSelectedId(null)} + /> + ); + } + + if (listState.status === "loading") { + return ; + } + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const reports = listState.data as Array>; + if (!Array.isArray(reports) || reports.length === 0) { + return

No reports found.

; + } + + return ( +
    + {reports.map((report) => { + const id = String(report.id ?? report.reportId ?? ""); + const summary = + String(report.summary ?? report.reportType ?? "") || "Report"; + const status = String(report.status ?? ""); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function ReportDetail({ + origin, + pubkey, + generation, + reportId, + onBack, +}: { + origin: string; + pubkey: string; + generation: number; + reportId: string; + onBack: () => void; +}) { + const detailState = useAsyncLoad( + () => getAdminReport(origin, reportId), + [origin, pubkey, reportId], + generation, + ); + + return ( +
+ + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( +
+          {JSON.stringify(detailState.data, null, 2)}
+        
+ )} +
+ ); +} + +// ── Feedback tab ────────────────────────────────────────────────────────── + +function FeedbackTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + + const listState = useAsyncLoad( + () => listAdminFeedback(origin), + [origin, pubkey], + generation, + ); + + if (selectedId) { + return ( + setSelectedId(null)} + origin={origin} + pubkey={pubkey} + generation={generation} + /> + ); + } + + if (listState.status === "loading") return ; + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const items = listState.data as Array>; + if (!Array.isArray(items) || items.length === 0) { + return

No feedback found.

; + } + + return ( +
    + {items.map((item) => { + const id = String(item.id ?? item.feedbackId ?? ""); + // FeedbackSummary wire shape: bodySummary, receivedAt (camelCase via serde). + const text = String(item.bodySummary ?? item.body ?? "").slice(0, 120); + const createdAt = String(item.receivedAt ?? item.eventCreatedAt ?? ""); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +// ── Attachment viewer ───────────────────────────────────────────────────── + +function AttachmentViewer({ + origin, + pubkey, + feedbackId, + attachment, + panelGeneration, +}: { + origin: string; + pubkey: string; + feedbackId: string; + attachment: AttachmentMeta; + /** Generation from the parent panel — when this changes the attachment + * context has changed and any in-flight load result is stale. */ + panelGeneration: number; +}) { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const blobUrlRef = useRef(null); + // Per-load generation: incremented when a new load starts AND in cleanup so + // that unmount or panelGeneration change invalidates any in-flight load. + const loadGenRef = useRef(0); + // Keep current origin/pubkey in refs so the callback can compare against + // the rendered-at-call-time values without capturing stale closure copies. + const originRef = useRef(origin); + const pubkeyRef = useRef(pubkey); + originRef.current = origin; + pubkeyRef.current = pubkey; + + // On panelGeneration change (identity/origin switch) or unmount: + // invalidate any in-flight load and revoke the cached blob URL. + // biome-ignore lint/correctness/useExhaustiveDependencies: panelGeneration is a prop that drives cleanup re-registration; the cleanup body mutates refs, not reactive state + useEffect(() => { + return () => { + // Increment generation so any in-flight native callback sees a mismatch. + loadGenRef.current += 1; + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, [panelGeneration]); + + const load = useCallback(async () => { + // Capture snapshot of context at the moment this load starts. + const thisGen = ++loadGenRef.current; + const thisOrigin = origin; + const thisPubkey = pubkey; + + setLoading(true); + setError(null); + try { + const url = await fetchAdminAttachmentBlobUrl( + origin, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ); + + // Discard if a newer load started, the component was unmounted/context + // changed (loadGenRef incremented in cleanup), or origin/pubkey differ. + if ( + thisGen !== loadGenRef.current || + thisOrigin !== originRef.current || + thisPubkey !== pubkeyRef.current + ) { + URL.revokeObjectURL(url); + return; + } + + // Revoke any previous blob before replacing. + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = url; + setBlobUrl(url); + } catch (e) { + if (thisGen !== loadGenRef.current) return; + setError( + typeof e === "string" ? (e as AdminAttachmentErrorCode) : String(e), + ); + } finally { + if (thisGen === loadGenRef.current) setLoading(false); + } + }, [ + origin, + pubkey, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ]); + + if (error) { + const friendlyError: Record = { + admin_attachment_too_large: "Attachment exceeds the 10 MiB desktop cap.", + admin_attachment_mime_mismatch: + "Attachment MIME type does not match the imeta record.", + admin_attachment_size_mismatch: + "Attachment byte count does not match the imeta record.", + admin_attachment_network_error: "Network error fetching attachment.", + }; + return ( +
+ + {friendlyError[error] ?? `Error: ${error}`} +
+ ); + } + + if (!blobUrl) { + return ( + + ); + } + + if (attachment.mime.startsWith("image/")) { + return ( + Feedback attachment + ); + } + + return ( + + + Download attachment ({attachment.mime}) + + ); +} + +function FeedbackDetail({ + origin, + pubkey, + generation, + feedbackId, + onBack, +}: { + origin: string; + pubkey: string; + generation: number; + feedbackId: string; + onBack: () => void; +}) { + const detailState = useAsyncLoad( + () => getAdminFeedback(origin, feedbackId), + [origin, pubkey, feedbackId], + generation, + ); + + // Parse imeta attachment metadata from the relay's wire `tags: string[][]`. + // AdminFeedback is serialised camelCase by the relay (serde rename_all). + const attachments: AttachmentMeta[] = + detailState.status === "ok" + ? parseImetaAttachments( + (detailState.data as Record).tags, + ) + : []; + + return ( +
+ + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> +
+            {JSON.stringify(detailState.data, null, 2)}
+          
+ {attachments.length > 0 && ( +
+

Attachments

+ {attachments.map((a) => ( + + ))} +
+ )} + + )} +
+ ); +} + +// ── Tab bar ─────────────────────────────────────────────────────────────── + +type Tab = "reports" | "feedback"; + +function TabBar({ + activeTab, + onSelect, +}: { + activeTab: Tab; + onSelect: (tab: Tab) => void; +}) { + return ( +
+ {( + [ + { value: "reports" as const, label: "Reports", Icon: ShieldAlert }, + { + value: "feedback" as const, + label: "Feedback", + Icon: MessageSquare, + }, + ] as const + ).map(({ value, label, Icon }) => ( + + ))} +
+ ); +} + +// ── Shared helpers ──────────────────────────────────────────────────────── + +function LoadingSpinner() { + return ( +
+ + Loading… +
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +// ── Panel root ──────────────────────────────────────────────────────────── + +export function AdminConsolePanel({ + origin, + pubkey, +}: { + origin: string; + /** Active identity pubkey — all state is keyed on (pubkey, origin). */ + pubkey: string; +}) { + const [activeTab, setActiveTab] = useState("reports"); + // Increment whenever the (pubkey, origin) context changes to invalidate all + // in-flight useAsyncLoad effects via their effect-local `active` flags. + const generationRef = useRef(0); + const [generation, setGeneration] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey and origin are reactive props — effect fires when either changes to bump the generation fence + useEffect(() => { + generationRef.current += 1; + setGeneration(generationRef.current); + }, [pubkey, origin]); + + return ( +
+ + {activeTab === "reports" && ( + + )} + {activeTab === "feedback" && ( + + )} +
+ ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx new file mode 100644 index 0000000000..623197526b --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -0,0 +1,392 @@ +/** + * Settings card for the desktop admin console. + * + * Lets an operator enter the admin console URL (the value of `BUZZ_ADMIN_HOST` + * on their relay), then probes it to determine auth mode and whether the + * current app identity is on the allowlist. + * + * Identity boundary: the stateful body is rendered as + * `` so that React + * synchronously unmounts A's entire state tree before B is rendered. Logout + * (pubkeyHex → empty string) renders nothing, so A's probe state, saved + * origin, and panel are torn down at the render level — not in a passive effect. + * + * Renders the full admin panel only when probe state is `nip98Authorized`. + * All other states surface honest, actionable copy without false hope. + */ + +import { useEffect, useRef, useState } from "react"; +import { AlertCircle, CheckCircle2, Info, LoaderCircle } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { cn } from "@/shared/lib/cn"; +import { + getAdminOrigin, + probeAdminOrigin, + setAdminOrigin, + type AdminProbeState, +} from "./api"; +import { AdminConsolePanel } from "./AdminConsolePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; + +// ── Probe state → UI copy ───────────────────────────────────────────────── + +type ProbeUiState = + | { kind: "idle" } + | { kind: "probing" } + | { kind: "authorized"; origin: string } + | { kind: "denied"; pubkeyHex: string } + | { kind: "tokenMode" } + | { kind: "disabled"; origin: string } + | { kind: "notAdminApi" } + | { kind: "networkOrIntercepted" } + | { kind: "error"; message: string }; + +function ProbeStatusBadge({ uiState }: { uiState: ProbeUiState }) { + if (uiState.kind === "idle") return null; + if (uiState.kind === "probing") { + return ( + + + Probing… + + ); + } + if (uiState.kind === "authorized") { + return ( + + + Connected + + ); + } + if (uiState.kind === "denied") { + return ( + + + + Access denied + + + Your pubkey is not in{" "} + BUZZ_ADMIN_PUBKEYS. Ask your relay + operator to add: + + + {uiState.pubkeyHex} + + + Other possible causes: clock skew > 60 s, relay config mismatch, or + the relay is running{" "} + BUZZ_ADMIN_AUTH=token instead of{" "} + nip98. + + + ); + } + if (uiState.kind === "tokenMode") { + return ( + + + Bearer-token mode. Use the web console — the desktop app only supports + NIP-98 auth. + + ); + } + if (uiState.kind === "disabled") { + return ( + + + Auth is disabled on this relay. The admin console is accessible without + a credential. + + ); + } + if (uiState.kind === "notAdminApi") { + return ( + + + No admin API found at this origin. Check the URL matches{" "} + BUZZ_ADMIN_HOST. + + ); + } + if (uiState.kind === "networkOrIntercepted") { + return ( + + + Could not reach the relay. Check: network, TLS certificate, DNS, or + whether a VPN/SSO layer (e.g. Cloudflare Access) intercepts this host. + + ); + } + // error + return ( + + + {uiState.message} + + ); +} + +function probeStateToUiState( + state: AdminProbeState, + origin: string, + pubkeyHex: string, +): ProbeUiState { + switch (state) { + case "nip98Authorized": + return { kind: "authorized", origin }; + case "nip98Denied": + return { kind: "denied", pubkeyHex }; + case "tokenMode": + return { kind: "tokenMode" }; + case "disabled": + return { kind: "disabled", origin }; + case "notAdminApi": + return { kind: "notAdminApi" }; + case "networkOrIntercepted": + return { kind: "networkOrIntercepted" }; + } +} + +// ── Main card ───────────────────────────────────────────────────────────── + +export function AdminConsoleSettingsCard() { + const { data: identity } = useIdentityQuery(); + const pubkeyHex = identity?.pubkey ?? ""; + + return ( +
+ + {pubkeyHex ? ( + + ) : null} +
+ ); +} + +// ── Stateful session — keyed by pubkeyHex ───────────────────────────────── +// +// React's `key` prop causes the parent to unmount this component entirely when +// the pubkey changes. That means: +// - A→B switch: A's entire state tree (originInput, savedOrigin, probeUiState, +// isSaving, in-flight probes) is destroyed synchronously before B mounts. +// - Logout (pubkeyHex → ""): the parent renders `null`, so A's state is gone +// before any new render begins. +// +// This eliminates the passive-effect reset race where the parent rendered with +// B's pubkey and A's stale origin/authorized state for one render cycle. + +function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) { + const [originInput, setOriginInput] = useState(""); + const [savedOrigin, setSavedOrigin] = useState(null); + const [probeUiState, setProbeUiState] = useState({ + kind: "idle", + }); + const [isSaving, setIsSaving] = useState(false); + + // In-flight probe abort controller. Does not cancel the Tauri native request + // (not cancellable), but prevents a stale probe result from updating UI state. + const probeAbortRef = useRef(null); + + // Save/probe context token: captures (pubkey, origin) at the time a save + // starts. handleSave checks this before committing any state so a delayed + // save cannot repopulate the wrong session. + // + // On unmount, the cleanup effect below sets sessionTokenRef.current = null. + // Every handleSave continuation leg checks `sessionTokenRef.current !== token` + // (null !== token object) → returns early on all paths. This is StrictMode-safe: + // StrictMode's simulated cleanup fires the null assignment, then the re-mount + // re-arms the ref when the next handleSave sets `sessionTokenRef.current = token`. + type SessionToken = { pubkey: string; origin: string }; + const sessionTokenRef = useRef(null); + + // Synchronously abort any active probe and reset probe UI state. + // Call before starting a new probe or on any input change. + function abortAndResetProbe() { + probeAbortRef.current?.abort(); + probeAbortRef.current = null; + setProbeUiState({ kind: "idle" }); + } + + // Null sessionTokenRef on unmount so A's deferred handleSave continuation + // fails the token check on all legs after A's component is torn down. Paired + // with the load-saved-origin effect below: that effect has an explicit + // lint suppression; this cleanup-only effect has no deps and Biome accepts it. + useEffect(() => { + return () => { + sessionTokenRef.current = null; + }; + }, []); + + // Load saved origin on mount (runs once per session because the component + // is keyed by pubkeyHex — re-mount = new pubkey). + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-once effect; identity boundary is the key prop on this component — it unmounts/remounts on pubkey change, so [] is correct. + useEffect(() => { + let active = true; + void (async () => { + try { + const saved = await getAdminOrigin(pubkeyHex); + if (!active) return; + setSavedOrigin(saved); + setOriginInput(saved ?? ""); + if (saved) { + runProbe(saved); + } + } catch (e) { + if (!active) return; + // Surface storage/signing errors rather than silently degrading. + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + setSavedOrigin(null); + setOriginInput(""); + } + })(); + return () => { + active = false; + }; + }, []); // Empty: runs once per session mount; identity boundary is the key prop. + + function runProbe(origin: string) { + probeAbortRef.current?.abort(); + const controller = new AbortController(); + probeAbortRef.current = controller; + + setProbeUiState({ kind: "probing" }); + + void (async () => { + try { + const result = await probeAdminOrigin(origin); + if (controller.signal.aborted) return; + setProbeUiState(probeStateToUiState(result.state, origin, pubkeyHex)); + } catch (e) { + if (controller.signal.aborted) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + })(); + } + + async function handleSave() { + const trimmed = originInput.trim(); + // Capture (pubkey, origin) token at save-start time. The check below + // ensures a delayed completion cannot write into a different session. + const token: SessionToken = { pubkey: pubkeyHex, origin: trimmed }; + sessionTokenRef.current = token; + + setIsSaving(true); + abortAndResetProbe(); + try { + if (!trimmed) { + const canonical = await setAdminOrigin(null, pubkeyHex); + // Discard if the session changed while the native call was in flight. + if (sessionTokenRef.current !== token) return; + setSavedOrigin(canonical); + setProbeUiState({ kind: "idle" }); + return; + } + const canonical = await setAdminOrigin(trimmed, pubkeyHex); + if (sessionTokenRef.current !== token) return; + setSavedOrigin(canonical); + if (canonical) { + runProbe(canonical); + } else { + setProbeUiState({ kind: "idle" }); + } + } catch (e) { + if (sessionTokenRef.current !== token) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + if (sessionTokenRef.current === token) setIsSaving(false); + } + } + + const inputChanged = originInput.trim() !== (savedOrigin ?? ""); + const isAuthorized = + probeUiState.kind === "authorized" && savedOrigin !== null; + + return ( + <> +
+
+ { + setOriginInput(e.target.value); + // General reset: abort and clear probe state on every input + // change, not only when state is `probing`. This prevents a + // stale probe result from a previous value being committed. + abortAndResetProbe(); + }} + placeholder="https://admin.yourrelay.example.com" + spellCheck={false} + type="url" + value={originInput} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave(); + }} + /> + + {savedOrigin && ( + + )} +
+ +
+ +
+
+ + {isAuthorized && savedOrigin && ( + + )} + + ); +} diff --git a/desktop/src/features/admin-console/adminConsolePanel.test.mjs b/desktop/src/features/admin-console/adminConsolePanel.test.mjs new file mode 100644 index 0000000000..a49d83cdf6 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanel.test.mjs @@ -0,0 +1,907 @@ +/** + * Behavior and race tests for AdminConsoleSettingsCard / AdminConsoleSettingsSession. + * + * Tests mount the REAL production components (including the key-prop session + * boundary, sessionTokenRef fence, and abortAndResetProbe wiring) against a + * mocked Tauri IPC bridge and a real QueryClientProvider. + * + * This file uses the hand-rolled MinimalDocument shim (same pattern as + * useLoadArchivedObserverEvents.test.mjs) and covers prop-driven and query- + * driven tests that do NOT require native event dispatch through React 19's + * container-level delegation: + * + * What makes these tests authoritative — they fail if: + * - `pubkeyHex ? : null` render gate removed (authorized-logout-teardown) + * - `key={pubkeyHex}` boundary is removed (identity-switch test) + * - `active` flag cleanup is removed from useAsyncLoad (old-list-after-new-list) + * - the `getAdminOrigin()` catch is changed to silent-degrade (storage-error test) + * + * authorized-logout-teardown lives here (MinimalDocument, not jsdom) because the test is + * query-driven (act + qc.setQueryData + settle), not event-driven. The MinimalDocument + * suite handles async transitions cleanly without the jsdom global scheduler. + * + * Cross-identity delayed-save and all event-driven tests (origin-edit, detail-navigation, + * attachment-unmount, same-session-save-race) live in adminConsolePanelEvents.jsdom-test.mjs + * where fireEvent dispatches native events through React 19's container-level delegation. + * + * Also covers: + * - parseImetaAttachments wire contract (imported from AdminConsolePanel) + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +// ── Minimal DOM shim ────────────────────────────────────────────────────────── +// +// Installs the minimum DOM surface that React + react-dom/client need. +// Uses the same pattern as useLoadArchivedObserverEvents.test.mjs to avoid +// jsdom background timers that prevent the process from exiting cleanly. + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName?.toUpperCase?.() ?? tagName; + this.nodeName = this.tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + this.attributes = []; + this._data = {}; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get lastChild() { + return this.childNodes[this.childNodes.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get previousSibling() { + return null; + } + get nodeValue() { + return null; + } + set nodeValue(_v) {} + get textContent() { + return this.childNodes.map((c) => c.textContent ?? "").join(""); + } + set textContent(v) { + this.childNodes = []; + if (v) { + const t = globalThis.document.createTextNode(v); + this.appendChild(t); + } + } + appendChild(child) { + child.parentNode = this; + this.childNodes.push(child); + if (child.nodeType === 1) this.children.push(child); + return child; + } + removeChild(child) { + this.childNodes = this.childNodes.filter((c) => c !== child); + this.children = this.children.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.childNodes.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + newNode.parentNode = this; + this.childNodes.splice(i, 0, newNode); + if (newNode.nodeType === 1) this.children.push(newNode); + return newNode; + } + replaceChild(newNode, oldNode) { + const i = this.childNodes.indexOf(oldNode); + if (i >= 0) { + newNode.parentNode = this; + this.childNodes[i] = newNode; + const j = this.children.indexOf(oldNode); + if (j >= 0) this.children[j] = newNode; + } + return oldNode; + } + contains(node) { + if (!node) return false; + return this === node || this.childNodes.some((c) => c?.contains?.(node)); + } + setAttribute(name, value) { + this._data[name] = value; + } + getAttribute(name) { + return this._data[name] ?? null; + } + hasAttribute(name) { + return Object.hasOwn(this._data, name); + } + removeAttribute(name) { + delete this._data[name]; + } + querySelector(selector) { + // Support [data-testid='...'] and simple tag selectors. + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const tagMatch = selector.match(/^([a-zA-Z]+)$/); + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + return node; + } + } else if (tagMatch) { + if (node.tagName?.toLowerCase() === tagMatch[1].toLowerCase()) { + return node; + } + } + } + return null; + } + querySelectorAll(selector) { + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const results = []; + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + results.push(node); + } + } + } + return results; + } + *_allElements() { + for (const child of this.childNodes) { + yield child; + if (child._allElements) yield* child._allElements(); + } + } + get innerHTML() { + return this.childNodes + .map((c) => c.outerHTML ?? c.textContent ?? "") + .join(""); + } + set innerHTML(_v) {} + get outerHTML() { + return `<${this.tagName?.toLowerCase() ?? "div"}>...`; + } + focus() {} + blur() {} + getBoundingClientRect() { + return { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }; + } + cloneNode() { + return new MinimalNode(this.tagName); + } + get value() { + return this._value ?? ""; + } + set value(v) { + this._value = v; + } + get disabled() { + return this._disabled ?? false; + } + set disabled(v) { + this._disabled = v; + } + get type() { + return this._type ?? ""; + } + set type(v) { + this._type = v; + } + get checked() { + return this._checked ?? false; + } + set checked(v) { + this._checked = v; + } + get className() { + return this._className ?? ""; + } + set className(v) { + this._className = v; + } + get id() { + return this._id ?? ""; + } + set id(v) { + this._id = v; + } + get placeholder() { + return this._placeholder ?? ""; + } + set placeholder(v) { + this._placeholder = v; + } + get readOnly() { + return this._readOnly ?? false; + } + set readOnly(v) { + this._readOnly = v; + } + get tabIndex() { + return this._tabIndex ?? -1; + } + set tabIndex(v) { + this._tabIndex = v; + } + get href() { + return this._href ?? ""; + } + set href(v) { + this._href = v; + } + get src() { + return this._src ?? ""; + } + set src(v) { + this._src = v; + } + get alt() { + return this._alt ?? ""; + } + set alt(v) { + this._alt = v; + } + } + + class MinimalTextNode extends MinimalEventTarget { + constructor(value) { + super(); + this.nodeType = 3; + this.nodeName = "#text"; + this.nodeValue = value; + this.parentNode = null; + } + get textContent() { + return this.nodeValue; + } + set textContent(v) { + this.nodeValue = v; + } + contains(node) { + return this === node; + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + this.nodeName = "#document"; + this._body = null; + this._head = null; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + return new MinimalTextNode(value); + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeType = 8; + n.nodeValue = value; + return n; + } + createElementNS(_ns, tagName) { + return this.createElement(tagName); + } + get body() { + if (!this._body) { + this._body = this.createElement("body"); + } + return this._body; + } + get head() { + if (!this._head) { + this._head = this.createElement("head"); + } + return this._head; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + querySelector(sel) { + return this.body.querySelector(sel); + } + querySelectorAll(sel) { + return this.body.querySelectorAll(sel); + } + get documentElement() { + return this.body; + } + } + + const doc = new MinimalDocument(); + globalThis.document = doc; + globalThis.HTMLElement = MinimalNode; + globalThis.HTMLInputElement = MinimalNode; + globalThis.HTMLButtonElement = MinimalNode; + globalThis.HTMLDivElement = MinimalNode; + globalThis.HTMLSpanElement = MinimalNode; + globalThis.HTMLAnchorElement = MinimalNode; + globalThis.HTMLFormElement = MinimalNode; + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.SVGElement = MinimalNode; + globalThis.SVGSVGElement = MinimalNode; + globalThis.Text = MinimalTextNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.getComputedStyle = () => ({ + getPropertyValue: () => "", + setProperty: () => {}, + }); + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +globalThis.__TAURI_INTERNALS__ = { + invoke(cmd, args) { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback(_cb) { + return Math.random(); + }, +}; + +// ── Production imports ──────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; +import { + AdminConsolePanel, + parseImetaAttachments, +} from "./AdminConsolePanel.tsx"; + +// ── Deferred promise helper ─────────────────────────────────────────────────── + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Mount helpers ───────────────────────────────────────────────────────────── + +function makeQueryClient(pubkeyHex) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + // Always set identity to an object (even for empty pubkey) so React Query + // never calls queryFn = getIdentity (which would hit the unmocked IPC). + // Component reads pubkeyHex = identity?.pubkey ?? "" — so { pubkey: "" } + // gives pubkeyHex = "" (logged-out state). + qc.setQueryData(["identity"], { pubkey: pubkeyHex }); + return qc; +} + +function mountCard(qc) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(AdminConsoleSettingsCard), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +/** + * Mount AdminConsolePanel directly (not through the settings card). + * Used for panel-level race tests (list, detail, attachment). + */ +function mountPanel({ origin, pubkey }) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + await act(async () => { + root.render( + React.createElement(AdminConsolePanel, { origin: o, pubkey: p }), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +// Flush React effects and timers. +async function settle(ms = 20) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); +} + +afterEach(() => { + clearIpcHandlers(); +}); + +// ── parseImetaAttachments ───────────────────────────────────────────────────── + +test("parseImetaAttachments: parses a well-formed imeta tag", () => { + const sha256 = "a".repeat(64); + const tags = [ + [ + "imeta", + `url https://example.com/a.jpg`, + `m image/jpeg`, + `x ${sha256}`, + "size 1234", + ], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 1); + assert.equal(result[0].sha256, sha256); + assert.equal(result[0].mime, "image/jpeg"); + assert.equal(result[0].size, 1234); +}); + +test("parseImetaAttachments: skips tags that are not imeta", () => { + const tags = [ + ["p", "abc123"], + ["e", "def456"], + ]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects uppercase x hash", () => { + const sha256Upper = "A".repeat(64); + const tags = [["imeta", `x ${sha256Upper}`, "m image/png", "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects hash shorter than 64 chars", () => { + const tags = [["imeta", `x ${"a".repeat(63)}`, "m image/png", "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects hash longer than 64 chars", () => { + const tags = [["imeta", `x ${"a".repeat(65)}`, "m image/png", "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects missing m field", () => { + const sha256 = "b".repeat(64); + const tags = [["imeta", `x ${sha256}`, "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects missing size field", () => { + const sha256 = "c".repeat(64); + const tags = [["imeta", `x ${sha256}`, "m image/png"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects non-positive size", () => { + const sha256 = "d".repeat(64); + const tags = [["imeta", `x ${sha256}`, "m image/png", "size 0"]]; + assert.deepEqual(parseImetaAttachments(tags), []); + const tagsNeg = [["imeta", `x ${sha256}`, "m image/png", "size -1"]]; + assert.deepEqual(parseImetaAttachments(tagsNeg), []); +}); + +test("parseImetaAttachments: parses multiple imeta tags", () => { + const sha1 = "e".repeat(64); + const sha2 = "f".repeat(64); + const tags = [ + ["imeta", `x ${sha1}`, "m image/png", "size 111"], + ["imeta", `x ${sha2}`, "m image/jpeg", "size 222"], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 2); + assert.equal(result[0].sha256, sha1); + assert.equal(result[1].sha256, sha2); +}); + +test("parseImetaAttachments: returns empty array for non-array input", () => { + assert.deepEqual(parseImetaAttachments(null), []); + assert.deepEqual(parseImetaAttachments({}), []); + assert.deepEqual(parseImetaAttachments("imeta"), []); +}); + +test("parseImetaAttachments: extracts from camelCase AdminFeedback relay fixture", () => { + // Exact wire shape emitted by the relay (serde rename_all = "camelCase"). + const sha256 = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const fixture = { + id: "00000000-0000-0000-0000-000000000001", + reportType: "feedback", + bodySummary: "App crashes on startup", + body: "Full description here", + receivedAt: 1700000000, + tags: [ + [ + "imeta", + `url https://relay.example.com/files/${sha256}`, + `m image/png`, + `x ${sha256}`, + "size 98765", + ], + ], + }; + const result = parseImetaAttachments(fixture.tags); + assert.equal(result.length, 1); + assert.equal(result[0].sha256, sha256); + assert.equal(result[0].mime, "image/png"); + assert.equal(result[0].size, 98765); +}); + +// ── Component-level session boundary and race tests ─────────────────────────── +// +// Each test below mounts the production AdminConsoleSettingsCard (including +// AdminConsoleSettingsSession keyed by pubkeyHex) and drives Tauri IPC calls +// via deferred promises. These tests fail if the identity boundary or fences +// are removed from the production code. + +test("authorized-logout-teardown: A's session is gone when pubkeyHex becomes empty", async () => { + // Verifies the `pubkeyHex ? : null` render + // gate in AdminConsoleSettingsCard. Drives the full authorized→logout transition: + // mount with a real identity A, drive to authorized (input visible, panel rendered), + // then switch pubkeyHex to "" and assert both input and panel are gone. + // + // Fails if the render gate is removed: after the transition to pubkeyHex="", + // AdminConsoleSettingsSession re-mounts with empty pubkey and the input remains. + // + // Design: identical to identity-switch — act + qc.setQueryData + settle. + // React Query's notifyManager fires onStoreChange via setTimeout(0), which + // act() drains during the inner settle(). The MinimalDocument environment + // handles this cleanly without the jsdom global scheduler side-effects. + + const pubkeyA = "a".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + // A is authorized — input and panel must be present. + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA in authorized state"); + const panelA = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok(panelA, "admin-console-panel must render when A is authorized"); + + // Transition to logout — same pattern as identity-switch. + await act(async () => { + qc.setQueryData(["identity"], { pubkey: "" }); + await new Promise((r) => setTimeout(r, 25)); + }); + + // After the transition: gate renders null, both input and panel must be gone. + const inputAfter = container.querySelector( + "[data-testid='admin-origin-input']", + ); + const panelAfter = container.querySelector( + "[data-testid='admin-console-panel']", + ); + + await unmount(); + + assert.equal( + inputAfter, + null, + "admin origin input must not render when pubkeyHex is empty — render gate missing", + ); + assert.equal( + panelAfter, + null, + "admin-console-panel must not render after logout — render gate missing", + ); +}); +test("identity-switch: fresh session mounts with empty input on pubkey change", async () => { + // Verifies the key-prop boundary. Without `key={pubkeyHex}`, React reuses + // the component and A's origin state survives the switch to B. + + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA"); + assert.equal( + inputA.value, + originA, + "input must show A's saved origin after mount", + ); + + // Switch to pubkeyB — key prop causes a full remount of AdminConsoleSettingsSession. + // B has no saved origin, so the input must be empty. + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyB) return Promise.resolve(null); + // Reject any call with A's pubkey — must not fire after the switch. + return Promise.reject(new Error("unexpected pubkey after identity switch")); + }); + + await act(async () => { + qc.setQueryData(["identity"], { pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 25)); + }); + + const inputB = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputB, "input must render for pubkeyB"); + assert.equal( + inputB.value, + "", + "input must be empty for pubkeyB — key boundary ensures fresh state, not stale A origin", + ); + await unmount(); +}); + +test("storage-error surfaced: getAdminOrigin rejection shows error in UI", async () => { + // Verifies the mount-effect catch sets `{ kind: 'error', message }`. + // Removing error propagation from the catch (silent degrade) causes the + // error text to not appear. + + const pubkey = "c".repeat(64); + const errorMsg = "stored admin console origin is invalid (removed): bad json"; + setIpcHandler("get_admin_origin", () => Promise.reject(new Error(errorMsg))); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + // The error or its key fragment must be visible in the rendered tree. + const bodyText = container.textContent ?? ""; + const hasError = + bodyText.includes("invalid") || + bodyText.includes("bad json") || + bodyText.includes("removed") || + bodyText.includes("admin console origin"); + assert.ok( + hasError, + `error from getAdminOrigin must appear in UI; body text: "${bodyText.slice(0, 300)}"`, + ); + await unmount(); +}); + +// origin-edit (abortAndResetProbe wired to onChange) is covered by +// adminConsolePanelEvents.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── AdminConsolePanel race tests ────────────────────────────────────────────── +// +// These tests mount AdminConsolePanel directly (bypassing the settings card) +// and use deferred promises to simulate in-flight native requests. They verify +// the effect-local `active` flag cancellation in useAsyncLoad, the generation +// fence in AdminConsolePanel, and the loadGenRef cleanup in AttachmentViewer. + +test("old-list-after-new-list: stale list result does not replace new list after pubkey change", async () => { + // Verifies the effect-local `active` flag in useAsyncLoad. + // + // Scenario: panel renders with pubkeyA/originA → list query starts (deferred). + // Before it resolves, panel re-renders with pubkeyB/originB → a new list + // query starts. Then the old (A's) deferred resolves: the active flag in + // A's effect closure is already false (effect re-ran with B's deps), so + // A's result is discarded. Only B's result may commit. + // + // This test fails if useAsyncLoad's active-flag cleanup is removed, because + // A's result would overwrite B's list state. + + const originA = "https://admin-a.example.com"; + const originB = "https://admin-b.example.com"; + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + + const listDeferredA = deferred(); + const listDeferredB = deferred(); + + // First call returns A's deferred; subsequent calls return B's. + let callCount = 0; + setIpcHandler("admin_list_reports", () => { + callCount += 1; + if (callCount === 1) return listDeferredA.promise; + return listDeferredB.promise; + }); + + const { container, doRender, unmount } = mountPanel({ + origin: originA, + pubkey: pubkeyA, + }); + + // Render with A — list query starts and stays pending (no settle; would hang). + await act(async () => { + await doRender({ origin: originA, pubkey: pubkeyA }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Switch to B — triggers generation bump + effect cleanup (active = false for A). + // Re-render causes the effect to re-run with B's deps. + await act(async () => { + await doRender({ origin: originB, pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Now resolve A's stale list with a distinct marker item. + listDeferredA.resolve([ + { + id: "00000000-0000-0000-0000-000000000001", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "message", + target: "eeff", + reportType: "spam", + status: "STALE-A-RESULT", + createdAt: "2024-01-01T00:00:00Z", + }, + ]); + + // Flush A's resolution — active is false so it must not commit. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + // A's stale result must not appear — active flag was false. + const text = container.textContent ?? ""; + assert.ok( + !text.includes("STALE-A-RESULT"), + `stale list result from A must not appear after B renders; got: ${text.slice(0, 300)}`, + ); + + // Resolve B's list — this one is live. + listDeferredB.resolve([ + { + id: "00000000-0000-0000-0000-000000000003", + communityId: "00000000-0000-0000-0000-000000000004", + communityHost: "relay.example.com", + reportEventId: "1122", + reporterPubkey: "3344", + targetKind: "message", + target: "5566", + reportType: "feedback", + status: "LIVE-B-RESULT", + createdAt: "2024-01-02T00:00:00Z", + }, + ]); + + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const textAfter = container.textContent ?? ""; + assert.ok( + textAfter.includes("LIVE-B-RESULT"), + `B's live list result must appear; got: ${textAfter.slice(0, 300)}`, + ); + + await unmount(); +}); + +// detail-navigation and attachment-unmount (useAsyncLoad active flag, +// AttachmentViewer loadGenRef cleanup) are covered by +// adminConsolePanelEvents.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs new file mode 100644 index 0000000000..dcfac6dcf6 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -0,0 +1,1021 @@ +/** + * Event-driven behavior tests for AdminConsoleSettingsCard / + * AdminConsoleSettingsSession and AdminConsolePanel. + * + * This file runs with jsdom pre-installed (via --import ./test-jsdom-setup.mjs) + * so React 19's canUseDOM is true and isInputEventSupported is set correctly. + * fireEvent from @testing-library/react dispatches native events that travel + * through React 19's container-level event delegation, reaching production + * handlers. + * + * What these tests prove — they fail if: + * - `abortAndResetProbe()` is removed from input onChange + * → origin-edit goes red (stale probe commits, panel renders) + * - `sessionTokenRef` check is removed from handleSave + * → same-session-save-race goes red (stale save clobbers B's input) + * - `active = false` cleanup is removed from useAsyncLoad + * → detail-navigation goes red (stale detail commits) + * - `expectedPubkey` dropped from the set_admin_origin invocation path + * → cross-identity-delayed-save goes red (A's save lacks expectedPubkey) + * - unmount-cleanup effect removed (sessionTokenRef not nulled on unmount) + * → strict-mode-save goes red (StrictMode double-mount silently disables saves) + * + * What these tests also prove: + * - `loadGenRef.current += 1` cleanup removed from AttachmentViewer + * → blob-leak-on-back-navigation goes red (stale blob leaks without revocation) + * Note: the existing attachment-unmount test exercises the same guard but via + * origin/pubkey re-render which also updates originRef/pubkeyRef. The back- + * navigation test isolates loadGenRef by unmounting without context change. + * - `pubkeyHex ? : null` render gate removed + * → authorized-logout-teardown goes red (empty-pubkey session renders, input present) + * Note: this test lives in adminConsolePanel.test.mjs (MinimalDocument suite) because + * the jsdom React 19 global scheduler leaves pending promises when the gate is absent, + * causing the jsdom test runner to report CANCELLED instead of a clean AssertionError. + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +// ── Tauri IPC interceptor ──────────────────────────────────────────────────── +// +// @tauri-apps/api/core calls `window.__TAURI_INTERNALS__.invoke(...)` where +// `window` is the jsdom window object (set via test-jsdom-setup.mjs), not +// `globalThis`. Both globalThis.__TAURI_INTERNALS__ and window.__TAURI_INTERNALS__ +// must be set so all import paths reach the same mock. + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +const tauriMock = { + invoke(cmd, args) { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback(_cb) { + return Math.random(); + }, +}; +// Set on both globalThis and the jsdom window object so all access paths work. +globalThis.__TAURI_INTERNALS__ = tauriMock; +if (globalThis.window && globalThis.window !== globalThis) { + globalThis.window.__TAURI_INTERNALS__ = tauriMock; +} + +// ── Production imports ─────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; +import { AdminConsolePanel } from "./AdminConsolePanel.tsx"; + +// ── Deferred promise helper ────────────────────────────────────────────────── + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Mount helpers ──────────────────────────────────────────────────────────── + +function makeQueryClient(pubkeyHex) { + // gcTime: Infinity prevents React Query from garbage-collecting setQueryData + // entries before the component mounts its observer. gcTime: 0 races with + // the GC timer and is appropriate only for test teardown, not setup. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + // Always set identity data (even for empty pubkey) so React Query never calls + // queryFn = getIdentity (which would hit the unmocked IPC). + // Component reads pubkeyHex = identity?.pubkey ?? "" — so { pubkey: "" } + // produces pubkeyHex = "" which is the correct logged-out representation. + qc.setQueryData(["identity"], { pubkey: pubkeyHex }); + return qc; +} + +function mountCard(qc) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(AdminConsoleSettingsCard), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +function mountPanel({ origin, pubkey }) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + await act(async () => { + root.render( + React.createElement(AdminConsolePanel, { origin: o, pubkey: p }), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +async function settle(ms = 20) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); +} + +afterEach(() => { + clearIpcHandlers(); +}); + +// ── origin-edit ────────────────────────────────────────────────────────────── + +test("origin-edit: input change while probe in-flight discards stale probe result", async () => { + // Verifies that abortAndResetProbe() is wired to input onChange. + // + // Scenario: + // 1. Component mounts with a saved origin; initial probe resolves + // immediately to "disabled" (no panel rendered, no unmocked IPC). + // 2. User clicks Re-probe — new deferred probe starts. + // 3. User edits the input via fireEvent.change — onChange fires, calls + // abortAndResetProbe(), setting probeAbortRef.current.signal.aborted. + // 4. Stale probe resolves — the callback sees signal.aborted and returns + // early; probeUiState stays at { kind: "idle" } → panel never renders. + // + // Fails if abortAndResetProbe() is removed from the onChange handler: + // the stale probe commits "nip98Authorized" and the panel renders. + + const pubkey = "d".repeat(64); + const savedOrigin = "https://admin.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + // If the stale probe commits nip98Authorized, the admin panel would render + // and call these IPC commands. Mock them so the test doesn't hang. + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender } = mountCard(qc); + await doRender(); + await settle(25); + + // Re-probe button appears when savedOrigin is set. + const reprobe = container.querySelector( + "[data-testid='admin-probe-refresh']", + ); + assert.ok(reprobe, "re-probe button must appear when savedOrigin is set"); + + // Start a new deferred probe. + const probeDeferred = deferred(); + setIpcHandler("admin_probe", () => probeDeferred.promise); + + await act(async () => { + // fireEvent.click dispatches a native click — React's delegated onClick handler + // calls runProbe(), creating a new AbortController on probeAbortRef.current. + fireEvent.click(reprobe); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Edit the input while the probe is in-flight. fireEvent.change dispatches + // a native change event through React 19's container-level delegation, + // reaching the production onChange handler which calls abortAndResetProbe(). + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(input, "origin input must be present"); + + await act(async () => { + fireEvent.change(input, { + target: { value: "https://admin-new.example.com" }, + }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Resolve the stale probe — controller.signal.aborted is true because + // abortAndResetProbe() was called by onChange. The callback returns early. + // We resolve inside act() so React flushes the state update synchronously. + await act(async () => { + probeDeferred.resolve({ state: "nip98Authorized" }); + await new Promise((r) => setTimeout(r, 20)); + }); + + // The panel must NOT be visible — probeUiState is { kind: "idle" }, not + // "authorized". The stale nip98Authorized result was discarded. + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok( + panel === null, + "admin-console-panel must not render — stale probe discarded after onChange", + ); + const text = container.textContent ?? ""; + assert.ok( + !text.includes("Connected"), + `stale nip98Authorized must not commit; got: ${text.slice(0, 200)}`, + ); + + // Skip unmount() here — calling act(root.unmount) after a mutation-caused + // panel render would hang waiting for React cleanup. The assertions already + // proved the test. The afterEach clears IPC handlers; the container is GC'd. +}); + +// ── same-session save race ──────────────────────────────────────────────────── + +test("same-session-save-race: deferred save X does not clobber pending save Y", async () => { + // Verifies the sessionTokenRef fence in handleSave. + // + // The save button is disabled while isSaving=true. We use fireEvent.keyDown + // with Enter on the input to trigger handleSave() directly (via onKeyDown), + // bypassing the disabled save button. This lets both saves be in-flight + // simultaneously — each with its own sessionToken. + // + // Scenario: + // 1. Type X and press Enter — save X starts (deferred), token=X. + // 2. Type Y and press Enter while X is pending — save Y starts (deferred), + // token=Y replaces X's token on sessionTokenRef.current. + // 3. Resolve X late: token(X) != sessionTokenRef.current(Y) → returns early, + // no runProbe(originX). + // 4. Resolve Y: runProbe(originY) fires normally. + // + // Fails if sessionTokenRef checks are removed: X's continuation calls + // runProbe(originX) after Y has set its token, causing probeOrigins to + // contain originX. + + const pubkey = "e".repeat(64); + const originX = "https://admin-x.example.com"; + const originY = "https://admin-y.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(null)); + + let resolveX, resolveY; + let saveCount = 0; + setIpcHandler("set_admin_origin", () => { + saveCount += 1; + if (saveCount === 1) + return new Promise((r) => { + resolveX = r; + }); + return new Promise((r) => { + resolveY = r; + }); + }); + + // Track probe origins to detect if X erroneously fires a probe. + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(15); + + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(input, "input must be present"); + + // Type X and press Enter to start save X (deferred). + await act(async () => { + fireEvent.change(input, { target: { value: originX } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // X's save is now pending (isSaving=true). Type Y and press Enter — this + // calls handleSave() again despite isSaving=true, creating a new token(Y). + await act(async () => { + fireEvent.change(input, { target: { value: originY } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Both saves are now in-flight. Clear probes from any initial mount probes. + probeOrigins.length = 0; + + // Resolve X late. Token(X) != sessionTokenRef.current (Y replaced it). + // With token check: returns early, runProbe(originX) NOT called. + // Without token check: runProbe(originX) IS called -> probeOrigins has originX. + resolveX?.(originX); + await settle(20); + + assert.ok( + !probeOrigins.some((o) => o.includes("admin-x")), + `X's late save must not trigger a probe; probes after X resolved: ${JSON.stringify(probeOrigins)}`, + ); + + // Resolve Y — its probe fires normally with originY. + resolveY?.(originY); + await settle(20); + + assert.ok( + probeOrigins.some((o) => o.includes("admin-y")), + `Y's save must trigger a probe with originY; probes: ${JSON.stringify(probeOrigins)}`, + ); + + await unmount(); +}); + +// ── detail-navigation ──────────────────────────────────────────────────────── + +test("detail-navigation: stale detail result is discarded after navigating away", async () => { + // Verifies useAsyncLoad's effect-local active flag on detail fetch. + // + // Scenario: + // 1. Panel renders; list resolves immediately with one entry. + // 2. User clicks the report row → detail fetch A starts (active=true, + // waiting on detailDeferredA). + // 3. origin/pubkey changes → generation bumps → old effect cleanup: + // active=false. New effect starts → detail fetch B (detailDeferredB). + // 4. detailDeferredA resolves with "STALE-DETAIL-CONTENT" → active=false + // → result discarded. detailDeferredB stays pending → UI shows loading. + // + // Fails if the `active = false` cleanup is removed: fetch A has active=true, + // so "STALE-DETAIL-CONTENT" commits and appears in the DOM. + + const origin = "https://admin.example.com"; + const pubkey = "a".repeat(64); + + const listResult = [ + { + id: "00000000-0000-0000-0000-000000000099", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "message", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-01-01T00:00:00Z", + }, + ]; + + setIpcHandler("admin_list_reports", () => Promise.resolve(listResult)); + + // Two separate deferreds: A for the first (stale) fetch, B for the second. + // This prevents B from accidentally committing A's stale content when the + // deferred is shared. + const detailDeferredA = deferred(); + const detailDeferredB = deferred(); + let detailCallCount = 0; + setIpcHandler("admin_get_report", () => { + detailCallCount += 1; + return detailCallCount === 1 + ? detailDeferredA.promise + : detailDeferredB.promise; + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + + // Initial render + list resolution. + await act(async () => { + await doRender(); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Find a report row button and click via fireEvent. + const allButtons = container.querySelectorAll("button"); + let clickedReport = false; + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 0)); + }); + clickedReport = true; + break; + } + + assert.ok(clickedReport, "a report row button must exist and be clickable"); + + // Detail fetch A is in-flight (active=true). Change origin/pubkey → + // generation bumps → old effect cleanup: active=false. New effect starts + // (active=true) and calls admin_get_report → detailDeferredB. + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + await act(async () => { + await doRender({ + origin: "https://admin-2.example.com", + pubkey: "b".repeat(64), + }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Resolve stale fetch A. Its active=false → result discarded. + detailDeferredA.resolve({ + id: "00000000-0000-0000-0000-000000000099", + content: "STALE-DETAIL-CONTENT", + status: "STALE-DETAIL", + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const text = container.textContent ?? ""; + assert.ok( + !text.includes("STALE-DETAIL-CONTENT"), + `stale detail A must not appear (active=false); got: ${text.slice(0, 300)}`, + ); + + // Clean up: resolve B to avoid dangling promises. + detailDeferredB.resolve({ id: "skip", content: "done" }); + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + + await unmount(); +}); + +// ── attachment-unmount ─────────────────────────────────────────────────────── + +test("attachment-unmount: late blob URL is revoked and not committed after panel generation changes", async () => { + // Verifies AttachmentViewer's loadGenRef cleanup and per-load generation guard. + // + // Scenario: + // 1. Panel renders; Feedback tab clicked; list+detail resolve immediately. + // 2. "View attachment" button appears; user clicks it — load starts: + // thisGen = ++loadGenRef.current = 1. Fetch is deferred (pending). + // 3. Re-render with new origin/pubkey bumps panelGeneration → + // AttachmentViewer cleanup: loadGenRef.current += 1 = 2. originRef and + // pubkeyRef also update to the new values. + // 4. Attachment resolves: thisGen(1) !== loadGenRef.current(2) (and also + // thisOrigin !== originRef.current) — URL.revokeObjectURL called, + // setBlobUrl NOT called. + // + // Fails if loadGenRef.current is NOT incremented in the cleanup: loadGenRef + // stays at 1 after cleanup; the new load increments to 2 (thisGen2=2). The + // stale load still has thisGen(1) !== loadGenRef(2) — caught by the new-load + // counter. BUT: if there is NO new load (i.e. the new panel renders without + // loading the attachment), loadGenRef stays at 1 after cleanup; the stale + // load sees thisGen(1) == loadGenRef(1) — NOT caught without the mutation! + // + // Key: after the re-render, the new AttachmentViewer is freshly mounted + // (new origin/pubkey = new key or props); it has NOT started a load yet + // because the user hasn't clicked "View attachment" in the new context. + // So loadGenRef resets to 0 on the new instance. The stale load uses a + // SEPARATE instance's loadGenRef via ref capture — but actually, since + // AttachmentViewer unmounts on re-render (origin/pubkey change), its ref + // is gone. The check uses the ref captured in the closure: + // if (thisGen !== loadGenRef.current ...) + // loadGenRef.current is 1 (incremented in cleanup) and thisGen is 1 (pre- + // cleanup), so without the cleanup increment: 1 !== 1 is FALSE → blob committed. + // With the cleanup increment: loadGenRef.current becomes 2, so 1 !== 2 → revoke. + + const origin = "https://admin.example.com"; + const pubkey = "a".repeat(64); + const sha256 = "a".repeat(64); + + const feedbackItem = { + id: "00000000-0000-0000-0000-000000000011", + bodySummary: "Test feedback", + receivedAt: 1700000000, + tags: [ + [ + "imeta", + `url https://relay.example.com/files/${sha256}`, + "m image/png", + `x ${sha256}`, + "size 1000", + ], + ], + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([feedbackItem])); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackItem)); + + const attachDeferred = deferred(); + const revokedUrls = []; + const origRevoke = globalThis.URL?.revokeObjectURL; + if (!globalThis.URL) globalThis.URL = {}; + globalThis.URL.revokeObjectURL = (url) => { + revokedUrls.push(url); + if (origRevoke) origRevoke.call(globalThis.URL, url); + }; + globalThis.URL.createObjectURL = () => "blob:test-url"; + setIpcHandler( + "admin_fetch_feedback_attachment", + () => attachDeferred.promise, + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + + await act(async () => { + await doRender(); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Click the Feedback tab via fireEvent. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab button must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Navigate to feedback detail, then click "View attachment" to start the load. + let startedAttachmentLoad = false; + const allBtns = container.querySelectorAll("button"); + for (const btn of allBtns) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + const btnText = btn.textContent ?? ""; + if (btnText.includes("View attachment")) { + // Already at attachment button — start the load. + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 0)); + }); + startedAttachmentLoad = true; + break; + } + // Click feedback item to navigate to detail. + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + // Find "View attachment" in detail and click it. + const btnsAfterNav = container.querySelectorAll("button"); + for (const b of btnsAfterNav) { + if ((b.textContent ?? "").includes("View attachment")) { + await act(async () => { + fireEvent.click(b); + await new Promise((r) => setTimeout(r, 0)); + }); + startedAttachmentLoad = true; + break; + } + } + break; + } + + assert.ok( + startedAttachmentLoad, + '"View attachment" button must be found and clicked', + ); + + // Attachment fetch is in-flight (deferred). Change origin/pubkey to bump + // panelGeneration — triggers AttachmentViewer cleanup: loadGenRef.current += 1. + // The new panel renders but the user hasn't clicked "View attachment" again, + // so loadGenRef.current on the now-unmounted instance's ref = original+1. + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + await act(async () => { + await doRender({ + origin: "https://admin-2.example.com", + pubkey: "b".repeat(64), + }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Resolve the attachment fetch. With the cleanup increment: + // thisGen(1) !== loadGenRef.current(2) -> revoke, no blob committed. + // Without the cleanup increment: + // thisGen(1) == loadGenRef.current(1) AND thisOrigin(admin.example.com) + // !== originRef.current(admin-2.example.com) -> still revoke (origin check). + // So this test catches the mutation only if the origin/pubkey check is also + // removed. The loadGenRef test is most meaningful for detecting same-context + // concurrent loads — see the comment above. We include it here as defense- + // in-depth: if both loadGenRef AND the origin check were removed, the stale + // blob would commit. + attachDeferred.resolve(new ArrayBuffer(8)); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const img = container.querySelector("img"); + assert.equal( + img?.getAttribute("src") ?? null, + null, + "stale blob URL must not be committed to an img element after panel generation change", + ); + + if (origRevoke !== undefined) globalThis.URL.revokeObjectURL = origRevoke; + await unmount(); +}); + +// ── blob-leak-on-back-navigation ────────────────────────────────────────────────────────────── + +test("blob-leak-on-back-navigation: loadGenRef cleanup prevents orphaned blob URL", async () => { + // Isolates the loadGenRef.current += 1 cleanup in AttachmentViewer. + // + // Scenario: attachment fetch is in-flight, then the user navigates "Back to + // feedback" (onBack sets selectedId=null in FeedbackTab, unmounting + // FeedbackDetail and AttachmentViewer). At unmount the cleanup fires: + // loadGenRef.current += 1 ← MUTATION TARGET + // The late fetch resolves. Since origin/pubkey are UNCHANGED (no context + // change happened), only the loadGenRef check catches the mismatch: + // thisGen (pre-cleanup value) !== loadGenRef.current (incremented) → revoke + // + // Without the cleanup increment: + // thisGen === loadGenRef.current (both remain at 1) → all three guards pass + // → setBlobUrl called → blob URL committed to blobUrlRef.current with no + // revocation → orphaned blob URL leak. + // + // Fails if loadGenRef.current += 1 is removed from the cleanup. + + const origin = "https://admin.example.com"; + const pubkey = "a".repeat(64); + const sha256 = "a".repeat(64); + + const feedbackItem = { + id: "00000000-0000-0000-0000-000000000011", + bodySummary: "Test feedback", + receivedAt: 1700000000, + tags: [ + [ + "imeta", + `url https://relay.example.com/files/${sha256}`, + "m image/png", + `x ${sha256}`, + "size 1000", + ], + ], + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([feedbackItem])); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackItem)); + + const attachDeferred = deferred(); + const revokedUrls = []; + const origRevoke = globalThis.URL?.revokeObjectURL; + if (!globalThis.URL) globalThis.URL = {}; + globalThis.URL.revokeObjectURL = (url) => { + revokedUrls.push(url); + if (origRevoke) origRevoke.call(globalThis.URL, url); + }; + globalThis.URL.createObjectURL = () => "blob:back-nav-test-url"; + setIpcHandler( + "admin_fetch_feedback_attachment", + () => attachDeferred.promise, + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + + await act(async () => { + await doRender(); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Click Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Navigate to feedback detail. + let navigatedToDetail = false; + for (const btn of container.querySelectorAll("button")) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + if ((btn.textContent ?? "").includes("View attachment")) { + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 0)); + }); + navigatedToDetail = true; + break; + } + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + for (const b of container.querySelectorAll("button")) { + if ((b.textContent ?? "").includes("View attachment")) { + await act(async () => { + fireEvent.click(b); + await new Promise((r) => setTimeout(r, 0)); + }); + navigatedToDetail = true; + break; + } + } + break; + } + assert.ok( + navigatedToDetail, + "must navigate to feedback detail and start attachment load", + ); + + // Attachment fetch is now in-flight. Click "Back to feedback" — this + // unmounts FeedbackDetail (and AttachmentViewer within it) WITHOUT changing + // origin or pubkey. The cleanup fires: loadGenRef.current += 1. + const backBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("Back to feedback"), + ); + assert.ok( + backBtn, + "'Back to feedback' button must be present while detail is showing", + ); + await act(async () => { + fireEvent.click(backBtn); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Resolve the attachment fetch. With cleanup increment: + // thisGen (1) !== loadGenRef.current (2) → URL.revokeObjectURL("blob:back-nav-test-url") + // Without cleanup increment: + // thisGen (1) === loadGenRef.current (1) AND origin/pubkey unchanged + // → setBlobUrl called → orphaned blob, no revocation. + attachDeferred.resolve(new ArrayBuffer(8)); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + assert.ok( + revokedUrls.includes("blob:back-nav-test-url"), + `blob URL must be revoked on back-navigation; revokedUrls: ${JSON.stringify(revokedUrls)}`, + ); + + if (origRevoke !== undefined) globalThis.URL.revokeObjectURL = origRevoke; + await unmount(); +}); + +// ── cross-identity delayed save ─────────────────────────────────────────────── + +test("cross-identity-delayed-save: A's late save carries A's expectedPubkey and does not touch B's state", async () => { + // Verifies that set_admin_origin IPC is called with expectedPubkey = A's pubkey, + // and that A's late save completion does not alter B's component state. + // + // The cross-session boundary is enforced by key={pubkeyHex}: when pubkey changes, + // A's component unmounts and B's mounts fresh. A's deferred save resolves and + // its continuation calls runProbe — but React state updates on the unmounted A + // component are discarded. B's input and panel are unaffected. + // + // Scenario: + // 1. Mount with pubkeyA; drive to authorized (probe nip98Authorized, panel rendered). + // 2. Edit input and start save — deferred set_admin_origin with expectedPubkey=A. + // 3. Switch identity to pubkeyB while A's save is pending: + // - A's component is synchronously unmounted (key change). + // - B's component mounts fresh with no saved origin. + // 4. Resolve A's deferred save late. + // 5. Assert: + // a. The set_admin_origin call recorded expectedPubkey = pubkeyA. + // b. B's input is still empty (A's late state writes discarded by React). + // c. B's panel does not show A's origin as authorized. + // d. No admin_probe fires for A's origin after the identity switch. + // + // Fails if expectedPubkey is dropped from the set_admin_origin invocation path + // (api.ts forwarding): the recorded call has no expectedPubkey, so the Rust-level + // guard cannot enforce identity isolation. + + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + const originA = "https://admin-a.example.com"; + const newOriginA = "https://admin-a-new.example.com"; + + // Saved origin for A; B has none. + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + // Initial probe for A → authorized so the panel renders. + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + // A is authorized — input must show originA. + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA"); + + // Record all set_admin_origin calls. + const saveRecords = []; + let resolveSaveA; + setIpcHandler("set_admin_origin", (args) => { + saveRecords.push({ ...args }); + return new Promise((r) => { + resolveSaveA = r; + }); + }); + + // Edit input to newOriginA and press Enter to start a deferred save. + await act(async () => { + fireEvent.change(inputA, { target: { value: newOriginA } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(inputA, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // A's save is now in-flight (deferred). Switch to pubkeyB. + // A's component is synchronously unmounted (key change). + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyB) return Promise.resolve(null); + return Promise.resolve(null); + }); + // After switch, record admin_probe calls to detect any stale A probe firing. + const probeRecords = []; + setIpcHandler("admin_probe", (args) => { + probeRecords.push({ ...args }); + return Promise.resolve({ state: "disabled" }); + }); + await act(async () => { + qc.setQueryData(["identity"], { pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 20)); + }); + + // Resolve A's deferred save late. A's component is already unmounted — any + // React state updates from A's continuation are discarded. B remains untouched. + resolveSaveA?.(newOriginA); + await settle(30); + + // (a) The set_admin_origin IPC call must have carried expectedPubkey = pubkeyA. + assert.ok( + saveRecords.length >= 1, + "set_admin_origin must have been called at least once", + ); + assert.equal( + saveRecords[0]?.expectedPubkey, + pubkeyA, + `set_admin_origin must carry expectedPubkey = pubkeyA; got: ${JSON.stringify(saveRecords[0])}`, + ); + + // (b) B's input must still be empty (A's late state writes are discarded by React + // on the unmounted A component; they never reach B's component tree). + const inputB = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputB, "B's input must be present after identity switch"); + assert.equal( + inputB.value, + "", + `B's input must be empty after identity switch; got: "${inputB.value}"`, + ); + + // (c) B's panel must not show A's origin as authorized — B is not authorized. + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.equal( + panel, + null, + "admin-console-panel must not render for B — B has no authorized origin", + ); + + // (d) No admin_probe must have fired for A's origin after the identity switch. + // A's handleSave continuation calls runProbe(canonical) after the save resolves. + // The sessionTokenRef check prevents same-session concurrent saves from firing + // a stale probe, but it does not stop A's own continuation after A unmounts: + // A's sessionTokenRef still matches A's token, so the check passes and + // runProbe(newOriginA) fires as an IPC call. React discards the state update + // on the unmounted component, so B is unaffected — but the probe IPC fires. + // This assertion catches any such stale probe call: if a probe with A's origin + // is recorded here, production code is calling probeAdminOrigin after unmount. + const staleProbe = probeRecords.find( + (p) => p?.origin === originA || p?.origin === newOriginA, + ); + assert.equal( + staleProbe, + undefined, + `no admin_probe must fire for A's origin after identity switch; got: ${JSON.stringify(staleProbe)}`, + ); + + await unmount(); +}); + +// ── strict-mode-save ────────────────────────────────────────────────────────── + +test("strict-mode-save: probe fires after save under React.StrictMode double-mount", async () => { + // Verifies the StrictMode-safe unmount fence in AdminConsoleSettingsSession. + // + // React.StrictMode (used in desktop/src/main.tsx) double-invokes effects in + // development: setup → cleanup → setup. An isMountedRef-based fence + // (cleanup sets isMountedRef.current = false, no reset in setup body) leaves + // the ref permanently false after the double-mount, silently killing every + // save completion in dev builds. + // + // The correct fence nulls sessionTokenRef on unmount instead: + // useEffect(() => () => { sessionTokenRef.current = null; }, []) + // StrictMode's cleanup sets sessionTokenRef.current = null, then the setup + // re-runs handleSave's `sessionTokenRef.current = token` when a new save + // starts — so the fence is re-armed per save, not per mount. + // + // Fails if the unmount-cleanup effect is removed (isMountedRef variant or no + // fence): after StrictMode double-mount, handleSave continuation is either + // permanently blocked (isMountedRef=false) or the cross-identity stale probe + // from assertion (d) in cross-identity-delayed-save fires. + + const pubkey = "c".repeat(64); + const savedOrigin = "https://admin-strict.example.com"; + const canonicalOrigin = "https://admin-strict-canonical.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + + // Track probe invocations to verify the save drives a probe. + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + setIpcHandler("set_admin_origin", () => Promise.resolve(canonicalOrigin)); + + // gcTime: Infinity is critical: with gcTime: 0 StrictMode's simulated unmount + // GCs the seeded identity query before the component's observer re-subscribes, + // so the input never renders on the second mount. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + qc.setQueryData(["identity"], { pubkey }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + // Mount under React.StrictMode — triggers setup → cleanup → setup on all effects. + await act(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(AdminConsoleSettingsCard), + ), + ), + ); + }); + await settle(30); + + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok( + input, + "origin input must render after StrictMode double-mount — identity query not GC'd", + ); + + // Clear probes from the initial mount probe. + probeOrigins.length = 0; + + // Edit input and press Enter to trigger handleSave(). + const newOrigin = "https://admin-strict-new.example.com"; + await act(async () => { + fireEvent.change(input, { target: { value: newOrigin } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + await settle(30); + + // The probe must fire for the canonical origin returned by set_admin_origin. + // Fails if isMountedRef=false (from StrictMode cleanup) permanently blocks + // the handleSave continuation: probeOrigins stays empty. + assert.ok( + probeOrigins.some((o) => o === canonicalOrigin), + `probe must fire after save under StrictMode; probes: ${JSON.stringify(probeOrigins)}`, + ); + + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); +}); diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts new file mode 100644 index 0000000000..b34e92fc50 --- /dev/null +++ b/desktop/src/features/admin-console/api.ts @@ -0,0 +1,175 @@ +/** + * TypeScript wrappers for the desktop admin console Tauri commands. + * + * All network activity is native (Rust). The webview never constructs + * admin API URLs — it supplies typed arguments which the Rust layer maps + * to the closed route enum. + * + * State keying: every result is implicitly tied to `(activePubkey, origin)`. + * Callers must cancel in-flight queries on pubkey or origin change. + */ + +import { invokeTauri } from "@/shared/api/tauri"; +import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; + +// ── Probe ───────────────────────────────────────────────────────────────── + +/** + * Result of probing an admin origin. Each variant drives a distinct settings + * UI state. See `AdminProbeResult` in the Rust module for the full contract. + */ +export type AdminProbeState = + | "nip98Authorized" + | "nip98Denied" + | "tokenMode" + | "disabled" + | "notAdminApi" + | "networkOrIntercepted"; + +export type AdminProbeResult = { state: AdminProbeState }; + +/** + * Probe `origin` to determine the authentication mode and whether the current + * app keypair is authorised. + * + * Returns `nip98Authorized` only on a fully authenticated 2xx. All other + * states map directly to informational UI copy without further retries. + */ +export async function probeAdminOrigin( + origin: string, +): Promise { + return invokeTauri("admin_probe", { origin }); +} + +// ── Origin persistence ──────────────────────────────────────────────────── + +/** + * Return the saved admin console origin for the currently active pubkey, or + * `null` if none has been saved. + * + * `expectedPubkey` is forwarded to the Rust command as a defence-in-depth + * guard: if the active signing key no longer matches the pubkey that was + * active when the call was issued (delayed IPC after an identity switch), the + * Rust side rejects the read. Callers should pass the pubkey that was active + * when the request was initiated. + */ +export async function getAdminOrigin( + expectedPubkey?: string, +): Promise { + return invokeTauri("get_admin_origin", { expectedPubkey }); +} + +/** + * Validate, normalise, and save `rawOrigin` as the admin console origin for + * the current pubkey. Returns the canonical origin on success. + * Pass `null` to clear the saved origin. + * + * `expectedPubkey` is forwarded to the Rust command: if the active signing + * key no longer matches, the write is rejected so a delayed save cannot write + * identity A's input into identity B's storage namespace. + */ +export async function setAdminOrigin( + rawOrigin: string | null, + expectedPubkey?: string, +): Promise { + return invokeTauri("set_admin_origin", { + rawOrigin, + expectedPubkey, + }); +} + +// ── Data commands ───────────────────────────────────────────────────────── + +export type AdminReportsQuery = { + communityId?: string; + status?: string; + reportType?: string; + targetKind?: string; + after?: string; + before?: string; + limit?: number; +}; + +/** Fetch the deployment-wide reports list. */ +export async function listAdminReports( + origin: string, + query: AdminReportsQuery = {}, +): Promise { + return invokeTauri("admin_list_reports", { origin, query }); +} + +/** Fetch a single report's detail by ID. */ +export async function getAdminReport( + origin: string, + id: string, +): Promise { + return invokeTauri("admin_get_report", { origin, id }); +} + +/** Fetch the deployment-wide product feedback list. */ +export async function listAdminFeedback(origin: string): Promise { + return invokeTauri("admin_list_feedback", { origin }); +} + +/** Fetch a single feedback entry's detail (includes imeta attachment metadata). */ +export async function getAdminFeedback( + origin: string, + id: string, +): Promise { + return invokeTauri("admin_get_feedback", { origin, id }); +} + +// ── Attachment ──────────────────────────────────────────────────────────── + +/** + * Stable typed error codes returned by `admin_fetch_feedback_attachment`. + * These map to actionable UI states — never silently ignored. + */ +export type AdminAttachmentErrorCode = + | "admin_attachment_too_large" + | "admin_attachment_mime_mismatch" + | "admin_attachment_size_mismatch" + | "admin_attachment_invalid_hash" + | "admin_attachment_invalid_mime" + | "admin_attachment_invalid_size" + | "admin_attachment_network_error" + | "admin_attachment_redirect" + | string; // relay HTTP error codes like admin_attachment_relay_error_404 + +/** + * Fetch a feedback attachment as raw bytes, then construct a Blob URL. + * + * The caller MUST supply `expectedMime` and `expectedSize` from the + * server-validated `imeta` fields in the feedback detail response. The native + * layer validates the relay's `Content-Type` and byte count against these + * expected values before returning; a mismatch yields a typed error code. + * + * The Blob is constructed from `expectedMime` — never a response header — + * so MIME is anchored to the server-validated imeta metadata. + * + * **Callers must `URL.revokeObjectURL(url)` when the URL is no longer needed.** + * + * @returns A `blob:` URL on success. + * @throws The typed error code string on failure. + */ +export async function fetchAdminAttachmentBlobUrl( + origin: string, + feedbackId: string, + sha256: string, + expectedMime: string, + expectedSize: number, +): Promise { + // The Rust command returns `tauri::ipc::Response` — arrives as ArrayBuffer. + const buffer = await invokeTauriRaw( + "admin_fetch_feedback_attachment", + { + origin, + feedbackId, + sha256, + expectedMime, + expectedSize, + }, + ); + const blob = new Blob([buffer], { type: expectedMime }); + return URL.createObjectURL(blob); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 162150e7c6..50c9855657 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -14,6 +14,7 @@ import { MessagesSquare, MonitorCog, Moon, + Server, ShieldAlert, Smartphone, Smile, @@ -85,6 +86,7 @@ import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { VoiceSettingsCard } from "./VoiceSettingsCard"; +import { AdminConsoleSettingsCard } from "@/features/admin-console/AdminConsoleSettingsCard"; export type SettingsSection = | "profile" @@ -102,7 +104,8 @@ export type SettingsSection = | "custom-emoji" | "local-archive" | "mobile" - | "updates"; + | "updates" + | "admin-console"; export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; @@ -123,6 +126,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "local-archive", "mobile", "updates", + "admin-console", ]; export function isSettingsSection(value: unknown): value is SettingsSection { @@ -239,6 +243,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Updates", icon: Download, }, + { + value: "admin-console", + label: "Admin console", + icon: Server, + }, ]; function formatThemeLabel(name: string): string { @@ -852,6 +861,8 @@ export function renderSettingsSection( return ; case "updates": return ; + case "admin-console": + return ; default: { const exhaustiveCheck: never = section; return exhaustiveCheck; diff --git a/desktop/test-jsdom-setup.mjs b/desktop/test-jsdom-setup.mjs new file mode 100644 index 0000000000..52217dc16d --- /dev/null +++ b/desktop/test-jsdom-setup.mjs @@ -0,0 +1,15 @@ +// Install jsdom globals before any test module (including React) is evaluated. +// This ensures React's canUseDOM = true so isInputEventSupported is set correctly. +import { JSDOM } from "jsdom"; +const dom = new JSDOM("", { url: "http://localhost" }); +const jsdomWindow = dom.window; +globalThis.window = jsdomWindow; +globalThis.document = jsdomWindow.document; +for (const key of Object.getOwnPropertyNames(jsdomWindow)) { + if (!(key in globalThis)) { + try { + globalThis[key] = jsdomWindow[key]; + } catch {} + } +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..5306247f6f 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -67,3 +67,72 @@ admission is not per-operator identity. Anyone admitted to the dashboard can read attachments for feedback records they can access. Per-person attribution or revocation requires authenticated operator identity at ingress/application level; this endpoint deliberately does not claim to provide it. + +## Authentication modes + +Set `BUZZ_ADMIN_AUTH` to one of: + +| Value | Behaviour | +|-------|-----------| +| `nip98` | Requires a `Authorization: Nostr ` signed with a key listed in `BUZZ_ADMIN_PUBKEYS` | +| `token` | Requires `Authorization: Bearer ` where the token equals `BUZZ_ADMIN_TOKEN` | +| `disabled` | No credential required (development use only) | + +The default is `token`. + +## Desktop app + +The Buzz desktop app ships a built-in admin console client. It does not require +a browser extension, separate web UI, or bearer token. It uses NIP-98 +authentication (mode `nip98` only). + +### Setup + +1. In your relay config, set `BUZZ_ADMIN_AUTH=nip98`. +2. Add your identity pubkey to `BUZZ_ADMIN_PUBKEYS`: + - Open **Settings → Admin console** in the Buzz desktop app. + - Copy the hex pubkey shown in the denied-access message, or find it under + **Settings → Profile**. + - Paste it into your relay's `BUZZ_ADMIN_PUBKEYS` environment variable. +3. In **Settings → Admin console**, paste the value of `BUZZ_ADMIN_HOST` (e.g. + `https://admin.yourrelay.example.com`) into the admin console URL field. + - The URL must be an origin only (scheme + host + optional port). No path, + query string, or fragment. + - The host must match `BUZZ_ADMIN_HOST` exactly, including case. The relay + compares byte-for-byte. Use lowercase. + - `http://` is accepted only for `localhost`, `127.x.x.x`, and `[::1]`; all + other hosts require `https://`. +4. Click **Save**. The app probes the origin and shows **Connected** when the + current identity is on the allowlist. + +### How it works + +The desktop client signs a NIP-98 kind-27235 event for each request URL and +method using the app's own keypair (the same identity used for messaging). It +does not require a separate admin key. + +The client uses a dedicated no-redirect HTTP client. A relay-issued redirect is +surfaced as an error rather than followed, so the `Authorization` header is +never forwarded to a different host. Every request URL is constructed natively +from a closed route enum — the webview cannot supply arbitrary paths. + +Response sizes are bounded: JSON responses are capped at 50 MiB (sized for a +200-row report list where each note field may reach the 256 KiB event-content +limit). Attachment previews are capped at 10 MiB. + +### Probe states + +| State | Meaning | +|-------|---------| +| **Connected** | NIP-98 mode, current identity authorised | +| **Access denied** | NIP-98 mode, identity not in `BUZZ_ADMIN_PUBKEYS`; or clock skew > 60 s | +| **Bearer-token mode** | Relay requires a bearer token. Use the web console — the desktop app supports NIP-98 only | +| **Auth disabled** | `BUZZ_ADMIN_AUTH=disabled`. Accessible without a credential | +| **No admin API** | Origin reachable but `/api/admin/v1` not found. Check the URL matches `BUZZ_ADMIN_HOST` | +| **Network/intercepted** | Network or TLS error, DNS failure, or an SSO/VPN layer (e.g. Cloudflare Access) intercepting the host | + +### Deployment note + +> **The desktop client is NOT advertised as usable against `admin.buzz.xyz` until +> the Cloudflare Access carve-out follow-up resolves (separate arc).** Self-hosted +> deployments without Cloudflare Access interception are unaffected.