From c9abd24c772854bf5814d4e7f4a4ab042e73b5cf Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 19 Oct 2026 20:11:33 +0200 Subject: [PATCH] Server: no built-in public host, no trusted proxy by default; OpenAPI server variable host (D-123) KVITT_PUBLIC_URL is required: every command refuses to start without it (compose always sets it). KVITT_TRUSTED_PROXY defaults to none, which the rate limiter never resolves. contracts/openapi.yaml and the utoipa document: servers[].url https://{host}:8443 with variable host (default kvitt.example.invalid). E-7 and E-12 examples on kvitt.example.invalid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../examples/E-12-create-invite/response.json | 2 +- contracts/examples/E-7-me/response.json | 2 +- contracts/openapi.yaml | 6 +- server/README.md | 4 +- .../crates/kvitt-server/src/api/rate_limit.rs | 48 +++++++++++- server/crates/kvitt-server/src/config.rs | 75 ++++++++++++++----- server/crates/kvitt-server/src/openapi.rs | 25 ++++++- .../crates/kvitt-server/tests/common/mod.rs | 3 + .../crates/kvitt-server/tests/core_api/cli.rs | 24 ++++++ .../kvitt-server/tests/core_api/invites.rs | 4 +- .../kvitt-server/tests/core_api/landing.rs | 6 +- .../kvitt-server/tests/skeleton/support.rs | 6 +- .../kvitt-server/tests/sync_api/serve.rs | 2 + 13 files changed, 168 insertions(+), 39 deletions(-) diff --git a/contracts/examples/E-12-create-invite/response.json b/contracts/examples/E-12-create-invite/response.json index f1e1498..867322b 100644 --- a/contracts/examples/E-12-create-invite/response.json +++ b/contracts/examples/E-12-create-invite/response.json @@ -3,7 +3,7 @@ "kind": "partner", "target_user_id": null, "code": "4N8RVT2KW6ZC", - "link": "https://icehack.asuscomm.com:8443/i/4N8RVT2KW6ZC?from=Jacob", + "link": "https://kvitt.example.invalid:8443/i/4N8RVT2KW6ZC?from=Jacob", "created_at": "2026-10-17T06:30:02.004Z", "expires_at": "2026-10-20T06:30:02.004Z" } diff --git a/contracts/examples/E-7-me/response.json b/contracts/examples/E-7-me/response.json index 1875581..58c6491 100644 --- a/contracts/examples/E-7-me/response.json +++ b/contracts/examples/E-7-me/response.json @@ -39,5 +39,5 @@ "server_version": 16 } ], - "public_url": "https://icehack.asuscomm.com:8443" + "public_url": "https://kvitt.example.invalid:8443" } diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index b9a0edf..c51af65 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -23,7 +23,11 @@ info: `protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`, `internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4. servers: - - url: https://icehack.asuscomm.com:8443 + - url: https://{host}:8443 + variables: + host: + default: kvitt.example.invalid + description: the owner-chosen Kvitt host name, SPEC §10.5 security: - device: [] diff --git a/server/README.md b/server/README.md index 3005595..0ecdb41 100644 --- a/server/README.md +++ b/server/README.md @@ -30,10 +30,10 @@ Configuration is environment variables only (SPEC §10.4, exhaustive): |---|---|---| | `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite` (+ `-wal`, `-shm`), `pictures/`, `apk/`. Created if missing. | | `KVITT_BIND` | `0.0.0.0:8080` | Listen address. | -| `KVITT_PUBLIC_URL` | `https://icehack.asuscomm.com:8443` | Base URL in invite links and the landing page. | +| `KVITT_PUBLIC_URL` | none: required | Base URL in invite links and the landing page, `https://:8443` on the Pi (set by compose). Every command refuses to start without it. | | `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). | | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. | -| `KVITT_TRUSTED_PROXY` | `caddy` | Host allowed to set `X-Forwarded-For`; a name that does not resolve means no proxy. | +| `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. | | `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. | The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR` diff --git a/server/crates/kvitt-server/src/api/rate_limit.rs b/server/crates/kvitt-server/src/api/rate_limit.rs index 7330f17..e3ca2b7 100644 --- a/server/crates/kvitt-server/src/api/rate_limit.rs +++ b/server/crates/kvitt-server/src/api/rate_limit.rs @@ -20,8 +20,9 @@ //! on restart; there is no table. //! //! The client IP is the TCP peer (axum `ConnectInfo`), or the last `X-Forwarded-For` entry when the peer -//! is an address of `KVITT_TRUSTED_PROXY` (Caddy, SPEC §8.5). The host name is resolved lazily and re-resolved at most -//! once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted (D-022). Without +//! is an address of `KVITT_TRUSTED_PROXY` (Nginx Proxy Manager, SPEC §8.5). The host name is resolved lazily and +//! re-resolved at most once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted +//! (D-022), and so does the default `none` ([`NO_TRUSTED_PROXY`]), which is never looked up. Without //! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket. use std::collections::HashMap; @@ -39,6 +40,7 @@ use tower::{Layer, Service}; use crate::audit::BoxFuture; use crate::auth::{bearer_token, is_token_shaped, sha256_hex}; +use crate::config::NO_TRUSTED_PROXY; use crate::error::AppError; use crate::time::Clock; use crate::{AppState, lock}; @@ -214,7 +216,7 @@ impl Limiter { return addrs.clone(); } let name = self.trusted_proxy.trim(); - let addrs: Vec = if name.is_empty() { + let addrs: Vec = if name.is_empty() || name.eq_ignore_ascii_case(NO_TRUSTED_PROXY) { Vec::new() } else if let Ok(ip) = name.parse::() { vec![ip] @@ -463,6 +465,46 @@ mod tests { ); } + fn limiter_trusting(trusted_proxy: &str) -> Limiter { + let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap(); + Limiter::new(Arc::new(FixedClock::new(now)), trusted_proxy.to_owned()) + } + + #[tokio::test] + async fn the_default_none_trusts_no_peer() { + let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)); + let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5)); + for value in [crate::config::DEFAULT_TRUSTED_PROXY, " NONE ", ""] { + let limiter = limiter_trusting(value); + assert_eq!( + limiter.proxy_addrs().await, + Vec::::new(), + "{value:?}" + ); + assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer); + } + } + + #[tokio::test] + async fn an_unresolvable_name_trusts_no_peer() { + // `.invalid` never resolves (RFC 6761). + let limiter = limiter_trusting("proxy.kvitt.invalid"); + let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)); + let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5)); + assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer); + } + + #[tokio::test] + async fn an_ip_literal_is_trusted() { + let limiter = limiter_trusting("192.168.1.20"); + let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)); + let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5)); + assert_eq!( + limiter.client_ip(Some(peer), Some(forwarded)).await, + forwarded + ); + } + fn token_key(i: u32) -> Key { Key::Token { ip: IpAddr::V4(Ipv4Addr::LOCALHOST), diff --git a/server/crates/kvitt-server/src/config.rs b/server/crates/kvitt-server/src/config.rs index d23dc12..49119fb 100644 --- a/server/crates/kvitt-server/src/config.rs +++ b/server/crates/kvitt-server/src/config.rs @@ -9,14 +9,16 @@ use crate::error::AppError; pub const DEFAULT_DATA_DIR: &str = "/data"; /// Default of `KVITT_BIND`. pub const DEFAULT_BIND: &str = "0.0.0.0:8080"; -/// Default of `KVITT_PUBLIC_URL`. -pub const DEFAULT_PUBLIC_URL: &str = "https://icehack.asuscomm.com:8443"; /// Default of `KVITT_FCM_CREDENTIALS`. pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json"; /// Default of `KVITT_FCM_PROJECT_ID`. pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337"; -/// Default of `KVITT_TRUSTED_PROXY`. -pub const DEFAULT_TRUSTED_PROXY: &str = "caddy"; +/// Default of `KVITT_TRUSTED_PROXY`: no peer is trusted to set `X-Forwarded-For` (the rate limiter never resolves +/// this value). `KVITT_PUBLIC_URL` has no default: the Kvitt host name is owner-chosen (SPEC §10.5), so the server +/// refuses to start without it. +pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY; +/// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space). +pub const NO_TRUSTED_PROXY: &str = "none"; /// Default of `RUST_LOG`. pub const DEFAULT_LOG_FILTER: &str = "info"; @@ -33,14 +35,15 @@ pub struct Config { pub data_dir: PathBuf, /// `KVITT_BIND`: listen address. pub bind: SocketAddr, - /// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Stored without a trailing slash. + /// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Required (no default). Stored without a + /// trailing slash. pub public_url: String, /// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing. pub fcm_credentials: PathBuf, /// `KVITT_FCM_PROJECT_ID`. pub fcm_project_id: String, /// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the - /// rate limiter; an unresolvable name means "no proxy". + /// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy". pub trusted_proxy: String, } @@ -51,13 +54,10 @@ impl Config { } /// Reads the variables through `lookup` (testable without touching the process environment). Empty values count - /// as unset. + /// as unset. A missing `KVITT_PUBLIC_URL` is an error. pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { - let get = |key: &str, default: &str| { - lookup(key) - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| default.to_owned()) - }; + let set = |key: &str| lookup(key).filter(|v| !v.trim().is_empty()); + let get = |key: &str, default: &str| set(key).unwrap_or_else(|| default.to_owned()); let bind_text = get("KVITT_BIND", DEFAULT_BIND); let bind: SocketAddr = bind_text.parse().map_err(|e| { AppError::new( @@ -65,12 +65,17 @@ impl Config { format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"), ) })?; + let public_url = set("KVITT_PUBLIC_URL").ok_or_else(|| { + AppError::new( + crate::error::ErrorCode::Internal, + "KVITT_PUBLIC_URL is not set: it must be the Kvitt base URL, \ + https://:8443 (SPEC §10.4)", + ) + })?; Ok(Self { data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)), bind, - public_url: get("KVITT_PUBLIC_URL", DEFAULT_PUBLIC_URL) - .trim_end_matches('/') - .to_owned(), + public_url: public_url.trim().trim_end_matches('/').to_owned(), fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)), fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID), trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY), @@ -97,18 +102,25 @@ impl Config { mod tests { use super::*; + const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443"; + #[test] fn defaults_when_unset_or_empty() { - let config = Config::from_lookup(|k| (k == "KVITT_BIND").then(String::new)).unwrap(); + let config = Config::from_lookup(|k| match k { + "KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()), + "KVITT_BIND" | "KVITT_TRUSTED_PROXY" => Some(String::new()), + _ => None, + }) + .unwrap(); assert_eq!(config.data_dir, PathBuf::from("/data")); assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap()); - assert_eq!(config.public_url, "https://icehack.asuscomm.com:8443"); + assert_eq!(config.public_url, PUBLIC_URL); assert_eq!( config.fcm_credentials, PathBuf::from("/secrets/fcm-service-account.json") ); assert_eq!(config.fcm_project_id, "kvitt-1337"); - assert_eq!(config.trusted_proxy, "caddy"); + assert_eq!(config.trusted_proxy, "none"); assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite")); } @@ -118,6 +130,7 @@ mod tests { "KVITT_DATA_DIR" => Some("/tmp/kvitt".into()), "KVITT_BIND" => Some("127.0.0.1:9000".into()), "KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()), + "KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()), _ => None, }) .unwrap(); @@ -127,10 +140,34 @@ mod tests { ); assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap()); assert_eq!(config.public_url, "http://192.168.1.10:8080"); + assert_eq!(config.trusted_proxy, "192.168.1.20"); + } + + #[test] + fn public_url_is_required() { + for value in [None, Some(""), Some(" ")] { + let error = Config::from_lookup(|k| { + (k == "KVITT_PUBLIC_URL") + .then_some(value) + .flatten() + .map(str::to_owned) + }) + .unwrap_err(); + assert!( + error.message.contains("KVITT_PUBLIC_URL"), + "{}", + error.message + ); + } } #[test] fn bad_bind_is_an_error() { - assert!(Config::from_lookup(|k| (k == "KVITT_BIND").then(|| "nope".into())).is_err()); + let config = Config::from_lookup(|k| match k { + "KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()), + "KVITT_BIND" => Some("nope".into()), + _ => None, + }); + assert!(config.is_err()); } } diff --git a/server/crates/kvitt-server/src/openapi.rs b/server/crates/kvitt-server/src/openapi.rs index 966ab4d..e40b80d 100644 --- a/server/crates/kvitt-server/src/openapi.rs +++ b/server/crates/kvitt-server/src/openapi.rs @@ -23,7 +23,7 @@ use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn}; use utoipa::openapi::response::{Response, ResponseBuilder}; use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type}; use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme}; -use utoipa::openapi::server::Server; +use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder}; use utoipa::openapi::{Content, Ref, RefOr, Required}; use utoipa::{Modify, OpenApi}; @@ -35,8 +35,15 @@ const INFO_DESCRIPTION: &str = "Shared-expense server for a two-person household timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \ strings. Common rules: SPEC §14.3.1."; -/// The production base URL (SPEC §7). -pub const PUBLIC_SERVER_URL: &str = "https://icehack.asuscomm.com:8443"; +/// The production base URL (SPEC §7, §14.3): the host is the server variable [`HOST_VARIABLE`], because the Kvitt +/// host name is owner-chosen (SPEC §10.5). +pub const PUBLIC_SERVER_URL: &str = "https://{host}:8443"; +/// The server variable in [`PUBLIC_SERVER_URL`]. +pub const HOST_VARIABLE: &str = "host"; +/// The default of [`HOST_VARIABLE`]: a reserved name that never resolves (RFC 6761), not a real host. +pub const HOST_VARIABLE_DEFAULT: &str = "kvitt.example.invalid"; +/// The description of [`HOST_VARIABLE`]. +const HOST_VARIABLE_DESCRIPTION: &str = "the owner-chosen Kvitt host name, SPEC §10.5"; const JSON: &str = api::JSON_CONTENT_TYPE; const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version"; @@ -191,7 +198,17 @@ impl Modify for CommonRules { openapi.info.description = Some(INFO_DESCRIPTION.to_owned()); openapi.info.license = None; openapi.info.contact = None; - openapi.servers = Some(vec![Server::new(PUBLIC_SERVER_URL)]); + openapi.servers = Some(vec![ + ServerBuilder::new() + .url(PUBLIC_SERVER_URL) + .parameter( + HOST_VARIABLE, + ServerVariableBuilder::new() + .default_value(HOST_VARIABLE_DEFAULT) + .description(Some(HOST_VARIABLE_DESCRIPTION)), + ) + .build(), + ]); openapi.security = Some(vec![SecurityRequirement::new( "device", Vec::::new(), diff --git a/server/crates/kvitt-server/tests/common/mod.rs b/server/crates/kvitt-server/tests/common/mod.rs index cccf0f6..4f3a2b7 100644 --- a/server/crates/kvitt-server/tests/common/mod.rs +++ b/server/crates/kvitt-server/tests/common/mod.rs @@ -22,6 +22,8 @@ use sqlx::SqliteConnection; use tower::ServiceExt; use uuid::Uuid; +/// `KVITT_PUBLIC_URL` of the harness, on the reserved `.invalid` name of the examples (E-7, E-12). +pub const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443"; pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001"; pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002"; pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001"; @@ -151,6 +153,7 @@ impl TestApp { let path = dir.0.to_string_lossy().into_owned(); let config = Config::from_lookup(|k| match k { "KVITT_DATA_DIR" => Some(path.clone()), + "KVITT_PUBLIC_URL" => Some(PUBLIC_URL.to_owned()), // No proxy is trusted unless a test says so. "KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()), _ => None, diff --git a/server/crates/kvitt-server/tests/core_api/cli.rs b/server/crates/kvitt-server/tests/core_api/cli.rs index 1b6df50..b4c29fe 100644 --- a/server/crates/kvitt-server/tests/core_api/cli.rs +++ b/server/crates/kvitt-server/tests/core_api/cli.rs @@ -39,6 +39,30 @@ fn printed_code(output: &Output) -> String { code.to_owned() } +#[test] +fn every_command_refuses_to_start_without_a_public_url() { + // SPEC §10.4: KVITT_PUBLIC_URL has no built-in host; compose always sets it. + let dir = TempDir::new(); + for args in [&["serve"][..], &["bootstrap"], &["migrate"]] { + let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server")) + .args(args) + .env("KVITT_DATA_DIR", &dir.0) + .env_remove("KVITT_PUBLIC_URL") + .env("RUST_LOG", "warn") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{args:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.starts_with("configuration: KVITT_PUBLIC_URL is not set"), + "{args:?}: {stderr}" + ); + assert!(output.stdout.is_empty(), "{args:?}"); + } + // Nothing was created: the server stopped before opening the database. + assert!(!dir.0.join("kvitt.sqlite").exists()); +} + #[tokio::test] async fn bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses() { let dir = TempDir::new(); diff --git a/server/crates/kvitt-server/tests/core_api/invites.rs b/server/crates/kvitt-server/tests/core_api/invites.rs index f8af414..1270c77 100644 --- a/server/crates/kvitt-server/tests/core_api/invites.rs +++ b/server/crates/kvitt-server/tests/core_api/invites.rs @@ -219,9 +219,7 @@ async fn e12_partner_invite_matches_the_example() { assert_eq!(code.len(), 12); assert_eq!( reply.json["link"], - json!(format!( - "https://icehack.asuscomm.com:8443/i/{code}?from=Jacob" - )) + json!(format!("{PUBLIC_URL}/i/{code}?from=Jacob")) ); assert_eq!(reply.json["created_at"], NOW); assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z"); diff --git a/server/crates/kvitt-server/tests/core_api/landing.rs b/server/crates/kvitt-server/tests/core_api/landing.rs index a57a898..0c4b628 100644 --- a/server/crates/kvitt-server/tests/core_api/landing.rs +++ b/server/crates/kvitt-server/tests/core_api/landing.rs @@ -34,9 +34,9 @@ async fn e4_english_by_default_with_both_links_and_the_grouped_code() { assert!(html.contains(">Open in Kvitt")); assert!(html.contains("7K3M-QX9D-2HPA")); assert!(html.contains( - "href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=7K3MQX9D2HPA\"" + "href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid%3A8443&code=7K3MQX9D2HPA\"" )); - assert!(html.contains("href=\"https://icehack.asuscomm.com:8443/apk/files/kvitt-412.apk\"")); + assert!(html.contains("href=\"https://kvitt.example.invalid:8443/apk/files/kvitt-412.apk\"")); } #[tokio::test] @@ -90,7 +90,7 @@ async fn e4_passes_the_admin_name_of_an_e12_link_into_the_app_link_only() { assert_eq!(reply.status, 200); let html = reply.text(); assert!(html.contains( - "href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=7K3MQX9D2HPA&from=%C3%85sa%20Berg\"" + "href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid%3A8443&code=7K3MQX9D2HPA&from=%C3%85sa%20Berg\"" ), "{html}"); // The name is not shown on the page itself. assert!(!html.contains("Åsa")); diff --git a/server/crates/kvitt-server/tests/skeleton/support.rs b/server/crates/kvitt-server/tests/skeleton/support.rs index 6e4b43a..cd0b254 100644 --- a/server/crates/kvitt-server/tests/skeleton/support.rs +++ b/server/crates/kvitt-server/tests/skeleton/support.rs @@ -47,8 +47,10 @@ impl TestApp { async fn migrated_to(target: Option) -> Self { let dir = TempDir::new(); - let config = Config::from_lookup(|k| { - (k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned()) + let config = Config::from_lookup(|k| match k { + "KVITT_DATA_DIR" => Some(dir.0.to_string_lossy().into_owned()), + "KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid:8443".to_owned()), + _ => None, }) .unwrap(); let pool = db::connect(&config.database_path()).await.unwrap(); diff --git a/server/crates/kvitt-server/tests/sync_api/serve.rs b/server/crates/kvitt-server/tests/sync_api/serve.rs index fbaa188..96763de 100644 --- a/server/crates/kvitt-server/tests/sync_api/serve.rs +++ b/server/crates/kvitt-server/tests/sync_api/serve.rs @@ -79,6 +79,7 @@ async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() { let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server")) .arg("rotate-epoch") .env("KVITT_DATA_DIR", app.data_dir()) + .env("KVITT_PUBLIC_URL", PUBLIC_URL) .env("RUST_LOG", "warn") .output() .unwrap(); @@ -96,6 +97,7 @@ fn binary(dir: &std::path::Path, bind: SocketAddr) -> Command { command .env("KVITT_DATA_DIR", dir) .env("KVITT_BIND", bind.to_string()) + .env("KVITT_PUBLIC_URL", PUBLIC_URL) .env("KVITT_FCM_CREDENTIALS", dir.join("no-such-key.json")) .env("RUST_LOG", "warn"); command