Skip to main content

bestool_canopy/
client.rs

1use std::{
2	fmt,
3	io::Write,
4	net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
5	sync::{Arc, OnceLock},
6	time::Duration,
7};
8
9use flate2::{Compression, write::GzEncoder};
10use hickory_resolver::{
11	ConnectionProvider, Resolver,
12	config::{ConnectionConfig, NameServerConfig, ResolverConfig},
13	net::runtime::TokioRuntimeProvider,
14};
15use jiff::Timestamp;
16use miette::{IntoDiagnostic, Result, WrapErr};
17use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
18use reqwest::Url;
19use serde::{Deserialize, Serialize};
20use time::{Duration as TimeDuration, OffsetDateTime};
21use tokio::sync::RwLock;
22use tracing::debug;
23
24use crate::Redacted;
25
26pub const DEFAULT_CANOPY_URL: &str = "https://meta.tamanu.app";
27
28/// Base URL for the tailscale-internal canopy endpoint.
29///
30/// On hosts that share the canopy tailnet, posting to this URL works without
31/// mTLS — the tailscale identity is the auth.
32pub const TAILSCALE_URL: &str = "https://canopy.tail53aef.ts.net";
33
34/// Bare hostname used for `resolve_to_addrs` overrides.
35const TAILSCALE_HOST: &str = "canopy.tail53aef.ts.net";
36
37/// Hardcoded tailscale IPs for canopy, used when tailscale DNS
38/// (100.100.100.100) is unreachable but the tailnet otherwise is.
39const CANOPY_HARDCODED_V4: Ipv4Addr = Ipv4Addr::new(100, 99, 98, 97);
40const CANOPY_HARDCODED_V6: Ipv6Addr =
41	Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0x9337, 0xfb52);
42
43/// How long renewed canopy certs are valid for.
44///
45/// Set well above [`CERT_RENEW_AFTER`] so a renewal failure doesn't immediately
46/// strand the client.
47const CERT_VALIDITY_DAYS: i64 = 6;
48
49/// How long to wait between scheduled cert renewals.
50///
51/// Renewal runs in a background task in the daemon; the legacy single-shot
52/// alerts command builds the client once and exits well within this window.
53pub const CERT_RENEW_AFTER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
54
55/// Timeout for the tailscale availability probe.
56const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
57
58/// Factory producing the base [`reqwest::ClientBuilder`] for canopy's clients.
59///
60/// The caller supplies this so it owns cross-cutting client config (user-agent,
61/// `SSLKEYLOGFILE`, proxies, …). Canopy invokes it whenever it needs to build or
62/// rebuild a client — at probe time, on mTLS cert renewal, and on reload — then
63/// layers its own concerns (mTLS identity, DNS overrides, timeouts) on top.
64pub type ClientBuilderFactory = Arc<dyn Fn() -> reqwest::ClientBuilder + Send + Sync>;
65
66/// Browser-style user-agent string, e.g. `bestool/1.2.3 (Linux 7.0.9 Arch Linux; x86_64)`.
67///
68/// `product` and `version` identify the calling binary; the OS comment is
69/// detected at runtime and cached.
70pub fn user_agent(product: &str, version: &str) -> String {
71	static OS_COMMENT: OnceLock<String> = OnceLock::new();
72	let os_comment = OS_COMMENT.get_or_init(|| {
73		let os = sysinfo::System::long_os_version()
74			.or_else(sysinfo::System::name)
75			.unwrap_or_else(|| std::env::consts::OS.to_owned());
76		format!("{os}; {}", sysinfo::System::cpu_arch())
77	});
78	format!("{product}/{version} ({os_comment})")
79}
80
81/// A [`reqwest::ClientBuilder`] carrying the `bestool` [`user_agent`] for `version`.
82///
83/// Convenience for callers that don't need any extra client config; suitable as
84/// the base of a [`ClientBuilderFactory`].
85pub fn client_builder(version: &str) -> reqwest::ClientBuilder {
86	reqwest::Client::builder().user_agent(user_agent("bestool", version))
87}
88
89/// Probe the canopy tailnet endpoint, returning a client routed to it if
90/// reachable.
91///
92/// The returned client carries the same DNS / hardcoded-IP resolution override
93/// the reporting client uses and presents **no** client certificate — callers
94/// reaching canopy this way authenticate by tailnet identity. Returns `None`
95/// when the tailnet endpoint isn't reachable, so callers can fall back to
96/// public mTLS.
97pub async fn tailscale_client(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
98	probe_tailscale(make_builder).await
99}
100
101/// Severities accepted by the canopy `/events` API.
102///
103/// Canopy narrowed its vocabulary from RFC 5424 to this five-level set; the
104/// retired syslog severities (`emergency`, `alert`, `notice`) are rejected by
105/// the strict enum validation on `POST /events`.
106#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
107#[serde(rename_all = "lowercase")]
108pub enum Severity {
109	Critical,
110	Error,
111	Warning,
112	Info,
113	Debug,
114}
115
116/// Payload for posting to `POST /events` on a canopy server.
117#[derive(Debug, Clone, Serialize)]
118pub struct NewEvent<'a> {
119	pub source: &'a str,
120	#[serde(rename = "ref")]
121	pub r#ref: &'a str,
122	pub message: &'a str,
123	#[serde(skip_serializing_if = "Option::is_none")]
124	pub description: Option<&'a str>,
125	#[serde(skip_serializing_if = "Option::is_none")]
126	pub severity: Option<Severity>,
127	#[serde(rename = "occurredAt", skip_serializing_if = "Option::is_none")]
128	pub occurred_at: Option<Timestamp>,
129	#[serde(skip_serializing_if = "Option::is_none")]
130	pub active: Option<bool>,
131}
132
133/// HTTP client with auth configured for talking to a canopy server.
134///
135/// Tries two auth paths in order of preference:
136/// 1. **Tailscale**: if the canopy tailnet endpoint is reachable, plain HTTPS
137///    works (auth is implicit via tailscale identity).
138/// 2. **mTLS**: a fresh self-signed cert from the device key, short-lived
139///    ([`CERT_VALIDITY_DAYS`]); for long-running daemons, [`Self::renew`]
140///    should tick on [`CERT_RENEW_AFTER`] to swap in a fresh cert before expiry.
141///
142/// [`Self::refresh`] re-probes tailscale and swaps modes on reload.
143pub struct CanopyClient {
144	device_key: Option<Redacted<String>>,
145	/// Tamanu version of the install this client speaks for. Sent verbatim in
146	/// the `X-Version` request header — canopy rejects events / status pushes
147	/// that don't carry one. Sourced from the running Tamanu install's
148	/// `package.json` (via `find_tamanu`); not the bestool / alertd version.
149	tamanu_version: String,
150	/// Produces the base client builder; see [`ClientBuilderFactory`].
151	make_builder: ClientBuilderFactory,
152	state: RwLock<State>,
153}
154
155enum State {
156	Tailscale(reqwest::Client),
157	Mtls(reqwest::Client),
158}
159
160impl State {
161	fn is_tailscale(&self) -> bool {
162		matches!(self, State::Tailscale(_))
163	}
164
165	fn http(&self) -> reqwest::Client {
166		match self {
167			State::Tailscale(http) | State::Mtls(http) => http.clone(),
168		}
169	}
170}
171
172impl fmt::Debug for CanopyClient {
173	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174		f.debug_struct("CanopyClient").finish_non_exhaustive()
175	}
176}
177
178impl CanopyClient {
179	/// Build a canopy client, preferring tailscale and falling back to mTLS.
180	///
181	/// Probes the tailscale canopy endpoint first; if reachable, uses it.
182	/// Otherwise, if a device key PEM is provided, builds an mTLS client.
183	/// Returns `Ok(None)` if neither path is available.
184	///
185	/// `tamanu_version` is the version of the Tamanu install this client
186	/// speaks for; sent on every request via the `X-Version` header.
187	///
188	/// `make_builder` supplies the base [`reqwest::ClientBuilder`] — see
189	/// [`ClientBuilderFactory`]. Use [`client_builder`] for a sensible default.
190	pub async fn new(
191		tamanu_version: impl Into<String>,
192		device_key_pem: Option<&str>,
193		make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
194	) -> Result<Option<Self>> {
195		let tamanu_version = tamanu_version.into();
196		let device_key = device_key_pem.map(|s| Redacted(s.to_owned()));
197		let make_builder: ClientBuilderFactory = Arc::new(make_builder);
198
199		if let Some(http) = probe_tailscale(&make_builder).await {
200			debug!("canopy: tailscale endpoint reachable, preferring it");
201			return Ok(Some(Self {
202				device_key,
203				tamanu_version,
204				make_builder,
205				state: RwLock::new(State::Tailscale(http)),
206			}));
207		}
208
209		if let Some(pem) = device_key_pem {
210			debug!("canopy: tailscale unreachable, falling back to mTLS");
211			let http = build_mtls_http(&make_builder, pem)?;
212			return Ok(Some(Self {
213				device_key,
214				tamanu_version,
215				make_builder,
216				state: RwLock::new(State::Mtls(http)),
217			}));
218		}
219
220		Ok(None)
221	}
222
223	/// Returns true if the client is currently using the tailscale path.
224	pub async fn is_tailscale(&self) -> bool {
225		self.state.read().await.is_tailscale()
226	}
227
228	/// Re-probe tailscale and swap modes if the picture has changed.
229	///
230	/// Intended to be called when the daemon receives a reload signal.
231	pub async fn refresh(&self) -> Result<()> {
232		if let Some(http) = probe_tailscale(&self.make_builder).await {
233			let mut state = self.state.write().await;
234			if !state.is_tailscale() {
235				debug!("canopy refresh: switching to tailscale path");
236			}
237			*state = State::Tailscale(http);
238			return Ok(());
239		}
240
241		if let Some(pem) = &self.device_key {
242			let http = build_mtls_http(&self.make_builder, &pem.0)?;
243			let mut state = self.state.write().await;
244			if state.is_tailscale() {
245				debug!("canopy refresh: tailscale dropped, falling back to mTLS");
246			}
247			*state = State::Mtls(http);
248			return Ok(());
249		}
250
251		debug!("canopy refresh: no auth path available, keeping current state");
252		Ok(())
253	}
254
255	/// Rebuild the underlying HTTP client with a fresh certificate.
256	///
257	/// No-op in tailscale mode (no cert to rotate). In mTLS mode, atomically
258	/// replaces the live client; in-flight requests continue with the old
259	/// client until they complete.
260	pub async fn renew(&self) -> Result<()> {
261		let Some(pem) = &self.device_key else {
262			return Ok(());
263		};
264		let mut state = self.state.write().await;
265		if state.is_tailscale() {
266			return Ok(());
267		}
268		*state = State::Mtls(build_mtls_http(&self.make_builder, &pem.0)?);
269		Ok(())
270	}
271
272	/// POST a status snapshot to the canopy server.
273	///
274	/// In tailscale mode, `base_url` is ignored and a `{TAILSCALE_URL}/public/status/{server_id}`
275	/// URL is used. In mTLS mode, posts to `{base_url}/status/{server_id}`.
276	///
277	/// The payload is free-form JSON; the canopy `/status` contract reserves the
278	/// top-level `health: []` key, whose entries each carry a `result` of
279	/// `passed | warning | failed | broken | skipped`. The body is gzip-encoded
280	/// with `Content-Encoding: gzip`.
281	pub async fn post_status(
282		&self,
283		base_url: &Url,
284		server_id: &str,
285		payload: &serde_json::Value,
286	) -> Result<()> {
287		let (http, url) = {
288			let state = self.state.read().await;
289			let url = match &*state {
290				State::Tailscale(_) => format!("{TAILSCALE_URL}/public/status/{server_id}")
291					.parse::<Url>()
292					.into_diagnostic()
293					.wrap_err("building tailscale /public/status URL")?,
294				State::Mtls(_) => base_url
295					.join(&format!("/status/{server_id}"))
296					.into_diagnostic()
297					.wrap_err("building /status URL")?,
298			};
299			(state.http(), url)
300		};
301
302		let raw = serde_json::to_vec(payload)
303			.into_diagnostic()
304			.wrap_err("serialising canopy /status payload")?;
305		let compressed = gzip_bytes(&raw)
306			.into_diagnostic()
307			.wrap_err("gzipping canopy /status payload")?;
308
309		debug!(
310			%url,
311			raw_bytes = raw.len(),
312			gzip_bytes = compressed.len(),
313			"posting status snapshot to canopy",
314		);
315
316		let response = http
317			.post(url)
318			.header("X-Version", &self.tamanu_version)
319			.header(reqwest::header::CONTENT_TYPE, "application/json")
320			.header(reqwest::header::CONTENT_ENCODING, "gzip")
321			.body(compressed)
322			.send()
323			.await
324			.into_diagnostic()
325			.wrap_err("posting status to canopy")?;
326
327		let status = response.status();
328		if !status.is_success() {
329			let body = response.text().await.unwrap_or_default();
330			return Err(miette::miette!("canopy /status returned {status}: {body}"));
331		}
332
333		Ok(())
334	}
335
336	/// GET a path on the canopy server, routed via tailscale when available.
337	///
338	/// In tailscale mode, the request goes to `{TAILSCALE_URL}{tailscale_path}`
339	/// (typically `/public/...`, the only mount that accepts tagged-device
340	/// tailscale callers). In mTLS mode, the request goes to `{base_url}{mtls_path}`.
341	///
342	/// Returns the raw response — the caller is responsible for status checks
343	/// and body parsing so they can choose how to fall back if the response
344	/// isn't usable.
345	pub async fn get(
346		&self,
347		base_url: &Url,
348		tailscale_path: &str,
349		mtls_path: &str,
350	) -> Result<reqwest::Response> {
351		let (http, url) = {
352			let state = self.state.read().await;
353			let url = match &*state {
354				State::Tailscale(_) => format!("{TAILSCALE_URL}{tailscale_path}")
355					.parse::<Url>()
356					.into_diagnostic()
357					.wrap_err("building tailscale GET URL")?,
358				State::Mtls(_) => base_url
359					.join(mtls_path)
360					.into_diagnostic()
361					.wrap_err("building mTLS GET URL")?,
362			};
363			(state.http(), url)
364		};
365
366		debug!(%url, "GET via canopy");
367		http.get(url)
368			.header("X-Version", &self.tamanu_version)
369			.send()
370			.await
371			.into_diagnostic()
372			.wrap_err("GET via canopy")
373	}
374
375	/// POST an event to the canopy server.
376	///
377	/// In tailscale mode, `base_url` is ignored and [`TAILSCALE_URL`] is used.
378	/// In mTLS mode, posts to `{base_url}/events`.
379	pub async fn post_event(&self, base_url: &Url, event: NewEvent<'_>) -> Result<()> {
380		let (http, url) = {
381			let state = self.state.read().await;
382			let url = match &*state {
383				State::Tailscale(_) => format!("{TAILSCALE_URL}/public/events")
384					.parse::<Url>()
385					.into_diagnostic()
386					.wrap_err("building tailscale /public/events URL")?,
387				State::Mtls(_) => base_url
388					.join("/events")
389					.into_diagnostic()
390					.wrap_err("building /events URL")?,
391			};
392			(state.http(), url)
393		};
394
395		debug!(
396			%url,
397			source = event.source,
398			r#ref = event.r#ref,
399			active = ?event.active,
400			"posting event to canopy"
401		);
402
403		let response = http
404			.post(url)
405			.header("X-Version", &self.tamanu_version)
406			.json(&event)
407			.send()
408			.await
409			.into_diagnostic()
410			.wrap_err("posting event to canopy")?;
411
412		let status = response.status();
413		if !status.is_success() {
414			let body = response.text().await.unwrap_or_default();
415			return Err(miette::miette!("canopy /events returned {status}: {body}"));
416		}
417
418		Ok(())
419	}
420}
421
422/// Probe the tailscale canopy endpoint.
423///
424/// Returns a configured `reqwest::Client` if `GET /public/servers` responds
425/// 2xx — anything else (timeout, non-2xx, transport error) returns `None` so
426/// the caller can fall back to mTLS.
427///
428/// Tries two paths in order:
429/// 1. Resolve `canopy` via the tailscale DNS server (100.100.100.100) and
430///    probe with those addresses.
431/// 2. Use hardcoded tailscale IPs for canopy and probe with those.
432///
433/// `/public/servers` is used because:
434/// - it lives under `/public/...`, the only mount that accepts tagged-device
435///   tailscale callers (everything else 403s with `tagged-device-not-allowed`);
436/// - it's a `GET` with no body, no `VersionHeader` requirement, and no auth;
437/// - it's read-only, so probing it has no side effects.
438async fn probe_tailscale(make_builder: &ClientBuilderFactory) -> Option<reqwest::Client> {
439	let dns_addrs: Vec<SocketAddr> = tailscale_resolver()
440		.lookup_ip("canopy")
441		.await
442		.ok()
443		.map(|addrs| addrs.iter().map(|ip| SocketAddr::new(ip, 443)).collect())
444		.unwrap_or_default();
445	if !dns_addrs.is_empty()
446		&& let Some(client) = try_probe(&dns_addrs, make_builder).await
447	{
448		return Some(client);
449	}
450
451	let hardcoded = [
452		SocketAddr::new(IpAddr::V4(CANOPY_HARDCODED_V4), 443),
453		SocketAddr::new(IpAddr::V6(CANOPY_HARDCODED_V6), 443),
454	];
455	debug!(
456		?hardcoded,
457		"canopy tailscale DNS lookup empty or probe failed, trying hardcoded IPs"
458	);
459	try_probe(&hardcoded, make_builder).await
460}
461
462async fn try_probe(
463	addrs: &[SocketAddr],
464	make_builder: &ClientBuilderFactory,
465) -> Option<reqwest::Client> {
466	let client = make_builder()
467		.timeout(TAILSCALE_PROBE_TIMEOUT)
468		.resolve_to_addrs(TAILSCALE_HOST, addrs)
469		.build()
470		.ok()?;
471
472	let url = format!("{TAILSCALE_URL}/public/servers");
473	match client.get(&url).send().await {
474		Ok(resp) if resp.status().is_success() => Some(client),
475		Ok(resp) => {
476			debug!(status = %resp.status(), ?addrs, "canopy tailscale probe: unexpected status");
477			None
478		}
479		Err(err) => {
480			debug!(?addrs, "canopy tailscale probe failed: {err}");
481			None
482		}
483	}
484}
485
486fn tailscale_resolver() -> Resolver<impl ConnectionProvider> {
487	Resolver::builder_with_config(
488		ResolverConfig::from_parts(
489			None,
490			vec!["tail53aef.ts.net.".parse().unwrap()],
491			vec![NameServerConfig::new(
492				"100.100.100.100".parse().unwrap(),
493				true,
494				vec![ConnectionConfig::udp()],
495			)],
496		),
497		TokioRuntimeProvider::default(),
498	)
499	.build()
500	.expect("tailscale resolver config is hardcoded and cannot fail to build")
501}
502
503fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
504	let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
505	encoder.write_all(bytes)?;
506	encoder.finish()
507}
508
509/// Build a short-lived self-signed client certificate from a P-256 device key
510/// PEM and wrap it as a reqwest mTLS [`Identity`].
511///
512/// Canopy identifies a device by its certificate's public key (SPKI), not by a
513/// CA chain, so a fresh self-signed cert from the device key is all that's
514/// needed. The same device key drives both the long-running canopy client here
515/// and the one-shot `canopy register` enrollment handshake, so they present the
516/// same identity to canopy.
517pub fn device_identity(device_key_pem: &str) -> Result<reqwest::Identity> {
518	let key_pair = KeyPair::from_pem(device_key_pem)
519		.into_diagnostic()
520		.wrap_err("parsing device key PEM")?;
521
522	let mut params = CertificateParams::new(vec!["device.local".into()])
523		.into_diagnostic()
524		.wrap_err("building certificate params")?;
525	params.distinguished_name = DistinguishedName::new();
526	params
527		.distinguished_name
528		.push(DnType::CommonName, "device.local");
529
530	let now = OffsetDateTime::now_utc();
531	params.not_before = now - TimeDuration::minutes(1);
532	params.not_after = now + TimeDuration::days(CERT_VALIDITY_DAYS);
533
534	let cert = params
535		.self_signed(&key_pair)
536		.into_diagnostic()
537		.wrap_err("self-signing certificate")?;
538
539	let mut combined = cert.pem();
540	combined.push('\n');
541	combined.push_str(&key_pair.serialize_pem());
542
543	reqwest::Identity::from_pem(combined.as_bytes())
544		.into_diagnostic()
545		.wrap_err("building reqwest TLS identity")
546}
547
548fn build_mtls_http(
549	make_builder: &ClientBuilderFactory,
550	device_key_pem: &str,
551) -> Result<reqwest::Client> {
552	let identity = device_identity(device_key_pem)?;
553
554	make_builder()
555		.identity(identity)
556		.use_rustls_tls()
557		.timeout(Duration::from_secs(30))
558		.build()
559		.into_diagnostic()
560		.wrap_err("building canopy HTTP client")
561}
562
563#[cfg(test)]
564mod tests {
565	use super::*;
566
567	const TEST_DEVICE_KEY: &str = "\
568-----BEGIN PRIVATE KEY-----
569MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVvhzsYiidp38GYn1
570KxD5Wipc/h8lglVsy1UFZq/SZbGhRANCAAT2EsEq7xjeWVnim9XwdYXga/LBbppm
571fXLgamTYOa/w9n/Ta64fiYWmN54kEd0DgnflJDLtID321Zz6xswvK/VN
572-----END PRIVATE KEY-----";
573
574	fn test_factory() -> ClientBuilderFactory {
575		Arc::new(reqwest::Client::builder)
576	}
577
578	#[test]
579	fn build_mtls_http_from_p256_key() {
580		// Direct mTLS-path build, bypassing the async constructor / tailscale probe.
581		let result = build_mtls_http(&test_factory(), TEST_DEVICE_KEY);
582		assert!(result.is_ok(), "{:?}", result.err());
583	}
584
585	#[test]
586	fn build_mtls_http_fails_on_garbage_key() {
587		assert!(build_mtls_http(&test_factory(), "not a real PEM").is_err());
588	}
589
590	#[tokio::test]
591	async fn renew_with_mtls_state_swaps_in_fresh_client() {
592		// Construct an mTLS-state client directly (no network probe) and renew it.
593		let http = build_mtls_http(&test_factory(), TEST_DEVICE_KEY).unwrap();
594		let client = CanopyClient {
595			device_key: Some(Redacted(TEST_DEVICE_KEY.to_owned())),
596			tamanu_version: "2.54.2".into(),
597			make_builder: test_factory(),
598			state: RwLock::new(State::Mtls(http)),
599		};
600		client.renew().await.expect("renew should succeed");
601		assert!(!client.is_tailscale().await);
602	}
603
604	#[tokio::test]
605	async fn renew_is_noop_in_tailscale_mode() {
606		// Tailscale-state client with no device key — renew is a no-op.
607		let http = reqwest::Client::new();
608		let client = CanopyClient {
609			device_key: None,
610			tamanu_version: "2.54.2".into(),
611			make_builder: test_factory(),
612			state: RwLock::new(State::Tailscale(http)),
613		};
614		client.renew().await.expect("renew should be a no-op");
615		assert!(client.is_tailscale().await);
616	}
617
618	#[test]
619	fn user_agent_has_product_and_os_comment() {
620		let ua = user_agent("bestool", "1.2.3");
621		assert!(
622			ua.starts_with("bestool/1.2.3 "),
623			"unexpected user-agent: {ua}"
624		);
625		assert!(ua.contains('('), "expected OS comment in: {ua}");
626		assert!(ua.ends_with(')'), "expected OS comment in: {ua}");
627		assert!(
628			ua.contains(sysinfo::System::cpu_arch().as_str()),
629			"expected arch in: {ua}"
630		);
631	}
632
633	#[test]
634	fn gzip_bytes_roundtrips() {
635		use flate2::read::GzDecoder;
636		use std::io::Read;
637
638		let original = br#"{"health":[{"check":"x","result":"passed"}]}"#;
639		let compressed = gzip_bytes(original).expect("gzip should succeed");
640		assert!(
641			compressed.starts_with(&[0x1f, 0x8b]),
642			"expected gzip magic bytes"
643		);
644		let mut decoder = GzDecoder::new(&compressed[..]);
645		let mut decompressed = Vec::new();
646		decoder.read_to_end(&mut decompressed).unwrap();
647		assert_eq!(decompressed, original);
648	}
649
650	#[test]
651	fn severity_serialises_lowercase() {
652		assert_eq!(
653			serde_json::to_string(&Severity::Warning).unwrap(),
654			"\"warning\""
655		);
656		assert_eq!(
657			serde_json::to_string(&Severity::Critical).unwrap(),
658			"\"critical\""
659		);
660	}
661
662	#[test]
663	fn new_event_omits_optional_fields() {
664		let evt = NewEvent {
665			source: "src",
666			r#ref: "host/alert:tgt",
667			message: "msg",
668			description: None,
669			severity: None,
670			occurred_at: None,
671			active: None,
672		};
673		let json = serde_json::to_string(&evt).unwrap();
674		assert!(json.contains("\"source\":\"src\""));
675		assert!(json.contains("\"ref\":\"host/alert:tgt\""));
676		assert!(json.contains("\"message\":\"msg\""));
677		assert!(!json.contains("description"));
678		assert!(!json.contains("severity"));
679		assert!(!json.contains("occurredAt"));
680		assert!(!json.contains("active"));
681	}
682
683	#[test]
684	fn new_event_serialises_occurred_at_as_camel_case() {
685		let evt = NewEvent {
686			source: "src",
687			r#ref: "ref",
688			message: "msg",
689			description: Some("desc"),
690			severity: Some(Severity::Warning),
691			occurred_at: Some("2025-01-01T00:00:00Z".parse().unwrap()),
692			active: Some(true),
693		};
694		let json = serde_json::to_string(&evt).unwrap();
695		assert!(json.contains("\"occurredAt\":"));
696		assert!(json.contains("\"description\":\"desc\""));
697		assert!(json.contains("\"severity\":\"warning\""));
698		assert!(json.contains("\"active\":true"));
699	}
700}