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