From 8c5627e3d1b56f8998f2ae48674517ba41e153e8 Mon Sep 17 00:00:00 2001 From: Tebbens4832 Date: Fri, 17 Jul 2026 12:10:37 -0500 Subject: [PATCH] =?UTF-8?q?NA-0652:=20capability=20document=20=E2=80=94=20?= =?UTF-8?q?GET=20/v1/server-info=20additive=20route,=20fixed=20two-key=20p?= =?UTF-8?q?robe,=20live-config=20document=20(D588,=20D-0012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOC-PROG-004 step 2 per locked decision L4 (additive-only, auth-gated, features-never-security). On a bearer relay an unauthorized request (missing OR wrong token, identical both ways — never a token oracle) gets 401 with EXACTLY {"server":"qsl-server","auth":{"mode":"bearer"}}; an authorized request (or any request on an open relay) gets the full document from LIVE config: server, version, name (RELAY_NAME, ""-safe), api [push_v1, pull_v1, pull_ack_lease_v1], auth.mode per RELAY_TOKEN presence, limits, retention.ttl_secs, directory none, attachments service_url (RELAY_ATTACHMENTS_SERVICE_URL, null-safe), kt none, min_client_version (RELAY_MIN_CLIENT_VERSION, null-safe, advisory). DOC-SRV-006 carries the contract rules; the three new env vars follow the RELAY_TOKEN env-only precedent and never fail startup; auth_ok is reused AS-IS (the ENG-0039 compare stays filed debt, untouched). push/pull/ack handlers, storage schema, auth mechanism, Cargo.toml and Cargo.lock byte-untouched; src/lib.rs diff is purely additive. Tests: tests/na0652_server_info.rs — probe-vs-full both auth modes, wrong-token byte-identity, exact-field-set guards (probe both nesting levels + full-doc top level), injected-config value tracking, real-binary env plumbing end-to-end. Full suite 108/0 across 26 sets (the NA-0642 100/25 baseline + this file); fmt + clippy -D warnings clean. Co-Authored-By: Claude Fable 5 --- DECISIONS.md | 8 + ...r_Info_Capability_Contract_v1.0.0_DRAFT.md | 92 +++++ packaging/runbook_ubuntu.md | 3 + packaging/systemd/relay.env.example | 9 + src/lib.rs | 96 +++++ tests/na0652_server_info.rs | 352 ++++++++++++++++++ 6 files changed, 560 insertions(+) create mode 100644 docs/server/DOC-SRV-006_Server_Info_Capability_Contract_v1.0.0_DRAFT.md create mode 100644 tests/na0652_server_info.rs diff --git a/DECISIONS.md b/DECISIONS.md index 7729039..b5f6ac1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -78,3 +78,11 @@ - **Decision:** qsl-server's store-and-forward queue becomes DURABLE (embedded SQLite via rusqlite/bundled, WAL + synchronous=FULL, single-file store at the required `STORE_PATH`; route tokens persisted only as SHA-256 digests; payloads stored verbatim as opaque blobs), and the delivery contract adds an ACKNOWLEDGED-PULL mode per the qsl-protocol D578 design-lock (option B, operator-chosen): `GET /v1/pull?ack=lease` returns messages WITHOUT deleting and leases them for `PULL_LEASE_SECS` (default 60 s); `POST /v1/pull/ack {"ids":[...]}` deletes ONLY leased copies (idempotent; scoped to the route; unleased duplicate copies per the NA-0275 contract survive); un-acked leases expire and the messages reappear. The LEGACY pull (`GET /v1/pull?max=N`, no ack parameter) keeps its exact delete-on-deliver contract and response shape — the current non-acking qsc client is not stranded. The 5-minute idle-route discard (`ROUTE_IDLE_TTL_MS`) is RETIRED (warn-and-ignore) and replaced by an operator-tunable retention TTL for undelivered messages (`RETENTION_TTL_SECS`, default 7 days, ceiling 30 days); delivered+acked messages are still forgotten immediately — the relay is reliable, not an archive. Startup is fail-closed: `STORE_PATH` has no default. - **Rationale:** The in-memory queue was demo-class: a restart dropped every queued message and idle routes discarded after 5 minutes, unacceptable for the DOC-PROG-003 self-host operator-path (Tier-1, step 1). Delete-on-pull loses a message if the puller crashes between pull and local persistence; the lease model closes that window without wire-semantic or E2EE change (payloads stay opaque, the relay stays blind, nothing precludes future E2EE read receipts as ordinary payloads). Route/pull semantics are a recorded-decision surface per D-0009/D-0010, so the contract change is recorded here; governance authority for the lane lives in qsl-protocol (NA-0642, D578, D-1265). - **References:** qsl-protocol NA-0642 / QSL-DIR-2026-07-13-578 (D578) / D-1265; D-0009; D-0010; `src/store.rs`; `src/lib.rs`; `src/main.rs`; `packaging/systemd/relay.env.example`; `packaging/systemd/qsl-server.service` (StateDirectory); `packaging/runbook_ubuntu.md`; `tests/na0642_durability_restart.rs`; `tests/na0642_ack_contract.rs`; `tests/na0642_retention_lifecycle.rs`; `tests/na0642_retention_logging.rs`; `tests/na0642_backward_compat.rs`; `tests/na0642_concurrency.rs`; `tests/na0642_store_privacy.rs` + +- **ID:** D-0012 + - **Status:** Accepted + - **Date:** 2026-07-17 + - **Goals:** G4, G5 + - **Decision:** qsl-server gains the ADDITIVE capability document `GET /v1/server-info` (qsl-protocol DOC-PROG-004 step 2, locked decision L4). On a bearer relay an unauthorized request (missing OR wrong token — identical both ways, never a token oracle) receives HTTP 401 with EXACTLY the fixed two-key probe `{"server":"qsl-server","auth":{"mode":"bearer"}}`; the probe registry is FIXED at implementation name + auth mode and may never grow operator config. An authorized request (or any request on an open relay) receives the full document from LIVE configuration: `server`, `version` (CARGO_PKG_VERSION), `name` (`RELAY_NAME`, "" when unset), `api` `["push_v1","pull_v1","pull_ack_lease_v1"]`, `auth.mode` `open|bearer` per `RELAY_TOKEN` presence, `limits.{max_body_bytes,max_queue_depth}`, `retention.ttl_secs`, `directory.mode` `"none"`, `attachments.service_url` (`RELAY_ATTACHMENTS_SERVICE_URL`, null when unset), `kt.mode` `"none"`, `min_client_version` (`RELAY_MIN_CLIENT_VERSION`, null when unset, ADVISORY — not enforced). Contract rules per DOC-SRV-006: additive-only evolution (clients ignore unknown fields; fields never removed/renamed/repurposed); the document gates FEATURES never SECURITY (absence means "not offered", never "not enforced"); served behind the SAME bearer gate as the relay routes; the three existing routes keep plain `ERR_UNAUTHORIZED` byte-identical. `/v1/push`, `/v1/pull`, `/v1/pull/ack` semantics, the storage schema, and the auth mechanism are UNCHANGED; the three new env vars are optional and never fail startup (the `RELAY_TOKEN` env-only precedent). + - **Rationale:** The GUI onboarding path (DOC-PROG-004 step 5) needs a real "test connection" contract: a client must be able to distinguish "QSL relay, needs auth" from "not a QSL relay" without leaking operator config, and an authorized client must learn the relay's actual limits and features from live values rather than assumptions. Locking additive-only evolution and features-never-security into the spec now prevents the capability document from ever becoming a downgrade vector. Route surface is a recorded-decision area per D-0009/D-0010/D-0011, so the addition is recorded here; governance authority lives in qsl-protocol (NA-0652, D588, D-1275). + - **References:** qsl-protocol NA-0652 / QSL-DIR-2026-07-17-588 (D588) / D-1275; D-0011; `src/lib.rs`; `docs/server/DOC-SRV-006_Server_Info_Capability_Contract_v1.0.0_DRAFT.md`; `packaging/systemd/relay.env.example`; `packaging/runbook_ubuntu.md`; `tests/na0652_server_info.rs` diff --git a/docs/server/DOC-SRV-006_Server_Info_Capability_Contract_v1.0.0_DRAFT.md b/docs/server/DOC-SRV-006_Server_Info_Capability_Contract_v1.0.0_DRAFT.md new file mode 100644 index 0000000..63bf854 --- /dev/null +++ b/docs/server/DOC-SRV-006_Server_Info_Capability_Contract_v1.0.0_DRAFT.md @@ -0,0 +1,92 @@ +# DOC-SRV-006 Server Info Capability Contract v1.0.0 (DRAFT) + +## Purpose + +Define the `GET /v1/server-info` capability document (NA-0652, directive D588; +DOC-PROG-004 step 2, locked decision L4): the mechanism by which a client asks +a relay what it offers. The route is ADDITIVE: `/v1/push`, `/v1/pull`, and +`/v1/pull/ack` semantics are unchanged by this contract. + +## Contract Rules (normative) + +1. **Additive-only evolution.** Fields are never removed, renamed, or + repurposed. New capabilities arrive as new fields or new values. Clients + MUST ignore unknown fields. The anticipated per-client admission work + (qsl-protocol ENG-0036) would later extend `auth.mode` with a new value + additively; nothing in this contract implements it. +2. **Features, never security.** The document advertises features and limits. + Nothing in it may relax, disable, or substitute for a security behavior. + Absence of a field means "not offered", never "not enforced". A client + MUST NOT weaken any security posture based on document contents. + `min_client_version` is ADVISORY data for clients; the server does not + enforce it. +3. **Served behind the same gate.** The full document sits behind the same + bearer gate (`RELAY_TOKEN`) as the relay routes. On a bearer relay an + unauthorized request receives the fixed probe (below); the probe registry + is FIXED at implementation name + auth mode and may never grow operator + config. The probe is a deliberate minimal disclosure (locked decision L4): + an unauthenticated probe of `/v1/push` already returns `ERR_UNAUTHORIZED` + today; the marginal disclosure is the implementation name. +4. **Probe-vs-full semantics.** The three existing routes keep their existing + auth behavior byte-identical (`401` with plain `ERR_UNAUTHORIZED`); they do + NOT adopt the probe body. Only `/v1/server-info` serves JSON on the + unauthorized path. + +## Route + +- `GET /v1/server-info`. No query parameters are defined. Responses are + `application/json`. + +## Probe (bearer relay, unauthorized) + +Returned with HTTP `401` for a missing OR wrong token — identical both ways, +so the response is never a token oracle: + +```json +{"server":"qsl-server","auth":{"mode":"bearer"}} +``` + +This is the complete registry. It identifies "a QSL relay that requires auth" +(distinguishable from not-a-relay) and nothing else. + +## Full Document (open relay: any request; bearer relay: valid token) + +Returned with HTTP `200`. Every value reflects LIVE configuration, never a +constant. Canonical shape and value sources: + +| Field | Source | +|---|---| +| `server` | `"qsl-server"` (implementation name) | +| `version` | `CARGO_PKG_VERSION` | +| `name` | `RELAY_NAME` env var; `""` when unset (empty-safe) | +| `api` | `["push_v1", "pull_v1", "pull_ack_lease_v1"]` (the served API set) | +| `auth.mode` | `"bearer"` iff `RELAY_TOKEN` set non-empty, else `"open"` | +| `limits.max_body_bytes` | live `MAX_BODY_BYTES` config | +| `limits.max_queue_depth` | live `MAX_QUEUE_DEPTH` config | +| `retention.ttl_secs` | live `RETENTION_TTL_SECS` config (validated value the store enforces) | +| `directory.mode` | `"none"` (no directory service offered) | +| `attachments.service_url` | `RELAY_ATTACHMENTS_SERVICE_URL` env var; `null` when unset. The relay does not proxy or validate it; it is data for the client | +| `kt.mode` | `"none"` (no key-transparency service offered) | +| `min_client_version` | `RELAY_MIN_CLIENT_VERSION` env var; `null` when unset; advisory only | + +## Configuration + +Three new env vars, matching the `RELAY_TOKEN` env-only precedent. All three +are optional; absent or empty values never fail startup: + +- `RELAY_NAME` — operator-set display string for the relay. +- `RELAY_ATTACHMENTS_SERVICE_URL` — attachments service URL to advertise. +- `RELAY_MIN_CLIENT_VERSION` — advisory minimum client version. + +## Test Anchors + +`tests/na0652_server_info.rs`: probe-vs-full in both auth modes; wrong-token +byte-identity (no oracle); exact-field-set guards on the probe (both nesting +levels) and the full document top level; injected-config value tracking; +end-to-end env plumbing through the real binary. + +## Decision + +Recorded as qsl-server DECISIONS `D-0012` (wire-contract surface, following +`D-0009`/`D-0010`/`D-0011`). Governance authority: qsl-protocol lane NA-0652, +directive QSL-DIR-2026-07-17-588. diff --git a/packaging/runbook_ubuntu.md b/packaging/runbook_ubuntu.md index f5dfc32..306f32a 100644 --- a/packaging/runbook_ubuntu.md +++ b/packaging/runbook_ubuntu.md @@ -43,6 +43,9 @@ Edit `/etc/qsl-server/relay.env`: - `PULL_LEASE_SECS=60` (visibility timeout for acknowledged pulls, `?ack=lease`) - `MAX_BODY_BYTES`, `MAX_QUEUE_DEPTH` as needed (template default 257 — the NA-0598 exact-4-MiB attachment needs 256 chunks + 1 manifest) +- `RELAY_NAME=`, `RELAY_ATTACHMENTS_SERVICE_URL=`, `RELAY_MIN_CLIENT_VERSION=` + (optional, NA-0652 — reported by `GET /v1/server-info` to authorized clients; + see DOC-SRV-006. Unset is fine; startup never fails on them) Apply config: diff --git a/packaging/systemd/relay.env.example b/packaging/systemd/relay.env.example index fafd1f7..4aa1fc0 100644 --- a/packaging/systemd/relay.env.example +++ b/packaging/systemd/relay.env.example @@ -21,3 +21,12 @@ PULL_LEASE_SECS=60 # Optional bearer token gate for /v1/push and /v1/pull. # Leave empty to disable auth. RELAY_TOKEN= + +# Optional server-info fields (NA-0652; GET /v1/server-info reports them to +# authorized clients). All three may be left unset; startup never fails on them. +# Operator-set display name for this relay ("" in the document when unset). +#RELAY_NAME= +# Attachments service URL to advertise (null in the document when unset). +#RELAY_ATTACHMENTS_SERVICE_URL= +# Advisory minimum client version (null when unset; NOT enforced by the server). +#RELAY_MIN_CLIENT_VERSION= diff --git a/src/lib.rs b/src/lib.rs index 6666225..a9df5aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -218,6 +218,33 @@ fn push_rate_refill_or_cap(value: usize) -> usize { value.min(MAX_PUSH_RATE_REFILL_PER_SEC_CEILING) } +// NA-0652 server-info descriptive fields. Reporting-only: nothing here gates +// any relay behavior (the capability document advertises FEATURES, never +// SECURITY — DOC-SRV-006). All three are optional; absent or empty env vars +// never fail startup, matching the RELAY_TOKEN env-only precedent. +#[derive(Clone, Debug, Default)] +pub struct ServerInfoCfg { + pub name: Option, + pub attachments_service_url: Option, + pub min_client_version: Option, +} + +impl ServerInfoCfg { + pub fn from_env() -> Self { + Self { + name: std::env::var("RELAY_NAME") + .ok() + .filter(|v| !v.trim().is_empty()), + attachments_service_url: std::env::var("RELAY_ATTACHMENTS_SERVICE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + min_client_version: std::env::var("RELAY_MIN_CLIENT_VERSION") + .ok() + .filter(|v| !v.trim().is_empty()), + } + } +} + #[derive(Clone)] pub struct AppState { // Durable store-and-forward queue (SQLite). Payloads are opaque blobs; @@ -229,6 +256,11 @@ pub struct AppState { limits: Limits, controls: ResourceControls, relay_token: Option, + // Validated copy of StoreConfig::retention_ttl_secs so /v1/server-info can + // report the live value the store enforces (same retention_ttl_or_error + // path Store::open applies). + retention_ttl_secs: usize, + server_info: ServerInfoCfg, } impl AppState { @@ -284,12 +316,31 @@ impl AppState { relay_token: Option, store_cfg: StoreConfig, ) -> Result { + Self::new_with_auth_controls_store_and_info( + limits, + controls, + relay_token, + store_cfg, + ServerInfoCfg::from_env(), + ) + } + + pub fn new_with_auth_controls_store_and_info( + limits: Limits, + controls: ResourceControls, + relay_token: Option, + store_cfg: StoreConfig, + server_info: ServerInfoCfg, + ) -> Result { + let retention_ttl_secs = retention_ttl_or_error(store_cfg.retention_ttl_secs)?; Ok(Self { store: Store::open(&store_cfg)?, push_rates: Arc::new(Mutex::new(HashMap::new())), limits, controls, relay_token, + retention_ttl_secs, + server_info, }) } @@ -374,9 +425,54 @@ pub fn app(state: AppState) -> Router { .route("/v1/push", post(push_message)) .route("/v1/pull", get(pull_message)) .route("/v1/pull/ack", post(ack_messages)) + .route("/v1/server-info", get(server_info)) .with_state(state) } +// NA-0652 capability document (DOC-SRV-006). Served behind the same bearer +// gate as the relay routes; on a bearer relay an unauthorized request gets the +// FIXED two-key probe — identical for a missing and a wrong token, so the +// response is never a token oracle, and the registry never grows operator +// config. The full document reports LIVE config only; it gates features, +// never security. +async fn server_info(State(st): State, headers: HeaderMap) -> impl IntoResponse { + if !auth_ok(&headers, st.relay_token.as_deref()) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "server": "qsl-server", + "auth": { "mode": "bearer" } + })), + ) + .into_response(); + } + let auth_mode = if st.relay_token.is_some() { + "bearer" + } else { + "open" + }; + ( + StatusCode::OK, + Json(serde_json::json!({ + "server": "qsl-server", + "version": env!("CARGO_PKG_VERSION"), + "name": st.server_info.name.clone().unwrap_or_default(), + "api": ["push_v1", "pull_v1", "pull_ack_lease_v1"], + "auth": { "mode": auth_mode }, + "limits": { + "max_body_bytes": st.limits.max_body_bytes, + "max_queue_depth": st.limits.max_queue_depth, + }, + "retention": { "ttl_secs": st.retention_ttl_secs }, + "directory": { "mode": "none" }, + "attachments": { "service_url": st.server_info.attachments_service_url }, + "kt": { "mode": "none" }, + "min_client_version": st.server_info.min_client_version, + })), + ) + .into_response() +} + fn channel_log_id(channel: &str) -> String { // Deterministic redaction-safe identifier (FNV-1a 64-bit rendered as hex). let mut state: u64 = 0xcbf29ce484222325; diff --git a/tests/na0652_server_info.rs b/tests/na0652_server_info.rs new file mode 100644 index 0000000..ed679fb --- /dev/null +++ b/tests/na0652_server_info.rs @@ -0,0 +1,352 @@ +// NA-0652 capability document contract (D588; DOC-SRV-006): +// - GET /v1/server-info on an OPEN relay (no RELAY_TOKEN) returns the full +// document to any request, auth.mode == "open". +// - On a BEARER relay an unauthorized request (missing OR wrong token — +// identical both ways, no oracle) gets HTTP 401 with EXACTLY the fixed +// two-key probe {"server":"qsl-server","auth":{"mode":"bearer"}}; a valid +// token gets the full document, auth.mode == "bearer". +// - Document values come from LIVE config (injected limits / retention TTL / +// RELAY_NAME / RELAY_ATTACHMENTS_SERVICE_URL / RELAY_MIN_CLIENT_VERSION), +// never constants; name is ""-safe, the other two are null-safe. +// - The top-level field set is exact: additions must consciously update the +// guard here (additive-only evolution). + +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use qsl_server::{app, AppState, Limits, ResourceControls, ServerInfoCfg, StoreConfig}; +use reqwest::StatusCode as ReqStatus; +use serde_json::Value; +use tokio::net::TcpListener; + +async fn spawn_server( + limits: Limits, + relay_token: Option, + store: StoreConfig, + info: ServerInfoCfg, +) -> (String, tokio::task::JoinHandle<()>) { + let state = AppState::new_with_auth_controls_store_and_info( + limits, + ResourceControls::new(8, 16, 16).unwrap(), + relay_token, + store, + info, + ) + .unwrap_or_else(|e| panic!("{e}")); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .unwrap_or_else(|e| panic!("{e}")); + let addr = listener.local_addr().unwrap_or_else(|e| panic!("{e}")); + let handle = tokio::spawn(async move { + axum::serve(listener, app(state)) + .await + .unwrap_or_else(|e| panic!("{e}")); + }); + (format!("http://{addr}"), handle) +} + +async fn spawn_default(relay_token: Option) -> (String, tokio::task::JoinHandle<()>) { + spawn_server( + Limits::new(1024 * 1024, 16).unwrap(), + relay_token, + StoreConfig::default(), + ServerInfoCfg::default(), + ) + .await +} + +fn expected_probe() -> Value { + serde_json::json!({ + "server": "qsl-server", + "auth": { "mode": "bearer" } + }) +} + +#[tokio::test] +async fn open_relay_unauthenticated_gets_full_document() { + let (base, _h) = spawn_default(None).await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(resp.status(), ReqStatus::OK); + let doc: Value = resp.json().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(doc["server"], "qsl-server"); + assert_eq!(doc["auth"]["mode"], "open"); + assert_eq!(doc["version"], env!("CARGO_PKG_VERSION")); +} + +#[tokio::test] +async fn bearer_missing_token_gets_exact_probe_401() { + let (base, _h) = spawn_default(Some("topsecret".to_string())).await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(resp.status(), ReqStatus::UNAUTHORIZED); + let body: Value = resp.json().await.unwrap_or_else(|e| panic!("{e}")); + // Exact-value equality doubles as the exact-field-set guard: any extra or + // missing key at either nesting level fails this comparison. + assert_eq!(body, expected_probe()); + let top = body.as_object().unwrap_or_else(|| panic!("not an object")); + assert_eq!(top.len(), 2); + let auth = body["auth"] + .as_object() + .unwrap_or_else(|| panic!("auth not an object")); + assert_eq!(auth.len(), 1); +} + +#[tokio::test] +async fn bearer_wrong_token_gets_byte_identical_probe() { + let (base, _h) = spawn_default(Some("topsecret".to_string())).await; + let client = reqwest::Client::new(); + let missing = client + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + let missing_status = missing.status(); + let missing_bytes = missing.bytes().await.unwrap_or_else(|e| panic!("{e}")); + let wrong = client + .get(format!("{base}/v1/server-info")) + .header("Authorization", "Bearer wrong") + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(missing_status, ReqStatus::UNAUTHORIZED); + assert_eq!(wrong.status(), ReqStatus::UNAUTHORIZED); + let wrong_bytes = wrong.bytes().await.unwrap_or_else(|e| panic!("{e}")); + // No oracle: a wrong token is indistinguishable from a missing one. + assert_eq!(missing_bytes, wrong_bytes); + let parsed: Value = serde_json::from_slice(&missing_bytes).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(parsed, expected_probe()); +} + +#[tokio::test] +async fn bearer_valid_token_gets_full_document() { + let (base, _h) = spawn_default(Some("topsecret".to_string())).await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/v1/server-info")) + .header("Authorization", "Bearer topsecret") + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(resp.status(), ReqStatus::OK); + let doc: Value = resp.json().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(doc["server"], "qsl-server"); + assert_eq!(doc["auth"]["mode"], "bearer"); +} + +#[tokio::test] +async fn document_values_track_injected_config() { + let (base, _h) = spawn_server( + Limits::new(4096, 9).unwrap(), + None, + StoreConfig { + retention_ttl_secs: 3600, + ..StoreConfig::default() + }, + ServerInfoCfg { + name: Some("Ops Relay".to_string()), + attachments_service_url: Some("https://attach.example".to_string()), + min_client_version: Some("0.9.0".to_string()), + }, + ) + .await; + let client = reqwest::Client::new(); + let doc: Value = client + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(doc["limits"]["max_body_bytes"], 4096); + assert_eq!(doc["limits"]["max_queue_depth"], 9); + assert_eq!(doc["retention"]["ttl_secs"], 3600); + assert_eq!(doc["name"], "Ops Relay"); + assert_eq!(doc["attachments"]["service_url"], "https://attach.example"); + assert_eq!(doc["min_client_version"], "0.9.0"); + assert_eq!(doc["version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + doc["api"], + serde_json::json!(["push_v1", "pull_v1", "pull_ack_lease_v1"]) + ); + assert_eq!(doc["directory"]["mode"], "none"); + assert_eq!(doc["kt"]["mode"], "none"); +} + +#[tokio::test] +async fn document_optional_fields_are_empty_and_null_safe_when_unset() { + let (base, _h) = spawn_default(None).await; + let client = reqwest::Client::new(); + let doc: Value = client + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(doc["name"], ""); + assert!(doc["attachments"]["service_url"].is_null()); + assert!(doc["min_client_version"].is_null()); +} + +#[tokio::test] +async fn full_document_top_level_field_set_is_exact() { + let (base, _h) = spawn_default(None).await; + let client = reqwest::Client::new(); + let doc: Value = client + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + let mut keys: Vec<&str> = doc + .as_object() + .unwrap_or_else(|| panic!("not an object")) + .keys() + .map(|k| k.as_str()) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "api", + "attachments", + "auth", + "directory", + "kt", + "limits", + "min_client_version", + "name", + "retention", + "server", + "version", + ] + ); +} + +// End-to-end env plumbing through the real binary: the three RELAY_-form vars +// (and RELAY_TOKEN) enter via the process environment exactly as an operator +// sets them, proving the main.rs -> AppState -> document path with nothing +// injected in-process. +struct Relay { + child: Child, + base: String, +} + +impl Relay { + fn spawn(extra_envs: &[(&str, &str)]) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_qsl-server")); + command + .env_clear() + .env("RUST_LOG", "info") + .env("BIND_ADDR", "127.0.0.1") + .env("PORT", "0") + .env("STORE_PATH", ":memory:") + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + for (name, value) in extra_envs { + command.env(name, value); + } + let mut child = command.spawn().unwrap_or_else(|e| panic!("{e}")); + let stdout = child.stdout.take().unwrap_or_else(|| panic!("no stdout")); + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + let mut sent = false; + for line in reader.lines() { + let Ok(line) = line else { break }; + if !sent { + if let Some(idx) = line.find("listening on ") { + let addr = line[idx + "listening on ".len()..].trim().to_string(); + let _ = tx.send(addr); + sent = true; + } + } + } + }); + let addr = rx + .recv_timeout(Duration::from_secs(10)) + .unwrap_or_else(|e| panic!("relay did not report listen address: {e}")); + Self { + child, + base: format!("http://{addr}"), + } + } + + fn stop(mut self) { + self.child.kill().unwrap_or_else(|e| panic!("{e}")); + self.child.wait().unwrap_or_else(|e| panic!("{e}")); + } +} + +#[tokio::test] +async fn relay_env_vars_flow_to_document_end_to_end() { + let relay = Relay::spawn(&[ + ("RELAY_TOKEN", "envsecret"), + ("RELAY_NAME", "Env Relay"), + ( + "RELAY_ATTACHMENTS_SERVICE_URL", + "https://attach.env.example", + ), + ("RELAY_MIN_CLIENT_VERSION", "1.2.3"), + ("RETENTION_TTL_SECS", "7200"), + ]); + let client = reqwest::Client::new(); + + let probe = client + .get(format!("{}/v1/server-info", relay.base)) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(probe.status(), ReqStatus::UNAUTHORIZED); + let probe_body: Value = probe.json().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(probe_body, expected_probe()); + + let doc: Value = client + .get(format!("{}/v1/server-info", relay.base)) + .header("Authorization", "Bearer envsecret") + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(doc["auth"]["mode"], "bearer"); + assert_eq!(doc["name"], "Env Relay"); + assert_eq!( + doc["attachments"]["service_url"], + "https://attach.env.example" + ); + assert_eq!(doc["min_client_version"], "1.2.3"); + assert_eq!(doc["retention"]["ttl_secs"], 7200); + relay.stop(); + + let bare = Relay::spawn(&[]); + let doc: Value = client + .get(format!("{}/v1/server-info", bare.base)) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(doc["auth"]["mode"], "open"); + assert_eq!(doc["name"], ""); + assert!(doc["attachments"]["service_url"].is_null()); + assert!(doc["min_client_version"].is_null()); + bare.stop(); +}