use std::collections::BTreeMap;
use bestool_canopy::schema::{
BackupCapabilitiesArgs, BackupCredentialsArgs, BackupPurpose, BackupTarget, BeginArgs,
BeginResponse, CheckSeverity, CompleteArgs, CompleteResponse, CredentialProcessOutput,
NewEvent, ReportArgs, RunOutcome, Severity,
};
use serde_json::{Value, json};
use tokio::sync::OnceCell;
use crate::actions::tamanu::{artifacts::Artifact, psql::Snippet};
const SPEC_URL: &str = "https://meta.tamanu.app/api/openapi.json";
async fn spec() -> &'static Value {
static SPEC: OnceCell<Value> = OnceCell::const_new();
SPEC.get_or_init(|| async {
reqwest::Client::new()
.get(SPEC_URL)
.send()
.await
.expect("fetching live canopy spec")
.error_for_status()
.expect("fetching live canopy spec")
.json()
.await
.expect("parsing live canopy spec")
})
.await
}
fn escape(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn resolve<'a>(spec: &'a Value, pointer: &str) -> &'a Value {
let value = spec
.pointer(pointer)
.unwrap_or_else(|| panic!("live canopy spec is missing {pointer}"));
match value.get("$ref").and_then(Value::as_str) {
Some(target) => spec
.pointer(target.trim_start_matches('#'))
.unwrap_or_else(|| panic!("dangling $ref {target} in live canopy spec")),
None => value,
}
}
fn validator_at(spec: &Value, pointer: &str) -> jsonschema::Validator {
let mut root = spec
.pointer(pointer)
.unwrap_or_else(|| panic!("live canopy spec is missing {pointer}"))
.clone();
root.as_object_mut()
.unwrap_or_else(|| panic!("schema at {pointer} is not an object"))
.insert("components".into(), spec["components"].clone());
jsonschema::validator_for(&root)
.unwrap_or_else(|err| panic!("compiling schema at {pointer}: {err}"))
}
fn assert_operation_exists(spec: &Value, path: &str, method: &str) {
let pointer = format!("/paths/{}/{method}", escape(path));
assert!(
spec.pointer(&pointer).is_some(),
"live canopy does not serve {} {path}",
method.to_uppercase(),
);
}
fn assert_valid(spec: &Value, pointer: &str, instance: &Value) {
let validator = validator_at(spec, pointer);
let errors: Vec<String> = validator
.iter_errors(instance)
.map(|err| format!("{err} (at instance path `{}`)", err.instance_path()))
.collect();
assert!(
errors.is_empty(),
"instance does not validate against {pointer}:\n{errors:#?}\ninstance: {instance:#}",
);
}
fn request_schema(path: &str, method: &str) -> String {
format!(
"/paths/{}/{method}/requestBody/content/application~1json/schema",
escape(path),
)
}
fn response_schema(path: &str, method: &str) -> String {
format!(
"/paths/{}/{method}/responses/200/content/application~1json/schema",
escape(path),
)
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn events_request_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/events", "post");
let event = NewEvent::builder()
.source("bestool-contract-test".to_owned())
.ref_("host/alert:target".to_owned())
.message("message".to_owned())
.description("description".to_owned())
.severity(Severity::Warning)
.occurred_at("2026-01-01T00:00:00Z".parse().unwrap())
.active(true)
.build();
let instance = serde_json::to_value(&event).unwrap();
assert_valid(spec, &request_schema("/events", "post"), &instance);
let mut invalid = instance.clone();
invalid["severity"] = json!("notice");
let validator = validator_at(spec, &request_schema("/events", "post"));
assert!(
!validator.is_valid(&invalid),
"spec validation accepted a retired severity; the validator is not checking refs",
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn severity_vocabulary_matches_spec() {
let spec = spec().await;
let spec_levels: Vec<&str> = resolve(spec, "/components/schemas/Severity")
.get("enum")
.and_then(Value::as_array)
.expect("Severity schema has an enum")
.iter()
.map(|v| v.as_str().expect("Severity enum values are strings"))
.collect();
use Severity::*;
const ALL: &[Severity] = &[Critical, Error, Warning, Info, Debug];
for severity in ALL {
match severity {
Critical | Error | Warning | Info | Debug => {}
}
}
let ours: Vec<String> = ALL
.iter()
.map(|s| {
serde_json::to_value(s)
.unwrap()
.as_str()
.unwrap()
.to_owned()
})
.collect();
for level in &spec_levels {
assert!(
serde_json::from_value::<Severity>(json!(level)).is_ok(),
"canopy severity {level:?} does not deserialise into bestool's Severity",
);
}
for level in &ours {
assert!(
spec_levels.contains(&level.as_str()),
"bestool severity {level:?} is not accepted by canopy (spec has {spec_levels:?})",
);
}
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn status_request_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/status/{server_id}", "post");
let instance = json!({
"health": [
{"check": "uptime", "result": "passed", "uptime_secs": 12345},
{"check": "disk", "result": "failed", "free_percent": 3},
{"check": "sync_lookup", "result": "broken"},
{"check": "fhir_jobs", "result": "skipped"},
{"check": "load", "result": "warning"},
],
"hostname": "test-host",
"pg_version": "16.4",
});
assert_valid(
spec,
&request_schema("/status/{server_id}", "post"),
&instance,
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn servers_probe_path_exists() {
assert_operation_exists(spec().await, "/servers", "get");
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn register_begin_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/servers/register/begin", "post");
let request = BeginArgs::builder()
.server_id("00000000-0000-0000-0000-000000000000".parse().unwrap())
.token("enrollment-token".to_owned())
.spki("c3BraQ==".to_owned())
.build();
let instance = serde_json::to_value(&request).unwrap();
assert_valid(
spec,
&request_schema("/servers/register/begin", "post"),
&instance,
);
let sample = json!({"nonce": "bm9uY2U=", "channel_binding_required": true});
assert_valid(
spec,
&response_schema("/servers/register/begin", "post"),
&sample,
);
let decoded: BeginResponse = serde_json::from_value(sample).unwrap();
assert_eq!(decoded.nonce, "bm9uY2U=");
assert!(decoded.channel_binding_required);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn register_complete_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/servers/register/complete", "post");
let request = CompleteArgs::builder()
.server_id("00000000-0000-0000-0000-000000000000".parse().unwrap())
.nonce("bm9uY2U=".to_owned())
.signature("c2lnbmF0dXJl".to_owned())
.build();
let instance = serde_json::to_value(&request).unwrap();
assert_valid(
spec,
&request_schema("/servers/register/complete", "post"),
&instance,
);
let sample = json!({
"server_id": "00000000-0000-0000-0000-000000000000",
"device_id": "11111111-1111-1111-1111-111111111111",
});
assert_valid(
spec,
&response_schema("/servers/register/complete", "post"),
&sample,
);
let decoded: CompleteResponse = serde_json::from_value(sample).unwrap();
assert_eq!(
decoded.server_id.to_string(),
"00000000-0000-0000-0000-000000000000"
);
assert_eq!(
decoded.device_id.to_string(),
"11111111-1111-1111-1111-111111111111"
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn tags_response_matches_decode() {
let spec = spec().await;
assert_operation_exists(spec, "/tags", "get");
let sample = json!({"role": "central", "fleet": "test"});
assert_valid(spec, &response_schema("/tags", "get"), &sample);
let decoded: BTreeMap<String, String> = serde_json::from_value(sample).unwrap();
assert_eq!(decoded["role"], "central");
let schema = resolve(spec, &response_schema("/tags", "get"));
assert_eq!(
schema.pointer("/additionalProperties/type"),
Some(&json!("string")),
"canopy /tags response is no longer a string→string map: {schema:#}",
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn snippets_response_matches_decode() {
let spec = spec().await;
assert_operation_exists(spec, "/bestool/snippets", "get");
let sample = json!({
"slow-queries": {"sql": "select 1", "description": "example"},
"bare": {"sql": "select 2", "description": null},
});
assert_valid(spec, &response_schema("/bestool/snippets", "get"), &sample);
let decoded: BTreeMap<String, Snippet> = serde_json::from_value(sample).unwrap();
assert_eq!(decoded["slow-queries"].sql, "select 1");
assert_eq!(decoded["bare"].description, None);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn artifacts_response_matches_decode() {
let spec = spec().await;
assert_operation_exists(spec, "/versions/{version}/artifacts", "get");
let sample = json!([{
"id": "00000000-0000-0000-0000-000000000000",
"artifact_type": "central",
"platform": "linux-x86_64",
"download_url": "https://example.com/artifact.tar.gz",
}]);
assert_valid(
spec,
&response_schema("/versions/{version}/artifacts", "get"),
&sample,
);
let decoded: Vec<Artifact> = serde_json::from_value(sample).unwrap();
assert_eq!(decoded[0].artifact_type, "central");
assert_eq!(
decoded[0].download_url.as_str(),
"https://example.com/artifact.tar.gz",
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn backup_capabilities_request_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/backup-capabilities", "post");
let types = vec!["tamanu-postgres".to_owned()];
let instance =
serde_json::to_value(BackupCapabilitiesArgs::builder().types(types).build()).unwrap();
assert_valid(
spec,
&request_schema("/backup-capabilities", "post"),
&instance,
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn backup_credentials_request_and_response_match_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/backup-credentials", "post");
let instance = serde_json::to_value(
BackupCredentialsArgs::builder()
.type_("tamanu-postgres".to_owned())
.purpose(BackupPurpose::Backup)
.build(),
)
.unwrap();
assert_valid(
spec,
&request_schema("/backup-credentials", "post"),
&instance,
);
let mut invalid = instance.clone();
invalid["purpose"] = json!("sideways");
let validator = validator_at(spec, &request_schema("/backup-credentials", "post"));
assert!(
!validator.is_valid(&invalid),
"spec accepted an invalid purpose; the validator is not checking refs",
);
let sample = json!({
"Version": 1,
"AccessKeyId": "AKIA",
"SecretAccessKey": "secret",
"SessionToken": "token",
"Expiration": "2026-05-21T13:00:00Z",
});
assert_valid(
spec,
&response_schema("/backup-credentials", "post"),
&sample,
);
let decoded: CredentialProcessOutput = serde_json::from_value(sample).unwrap();
assert_eq!(decoded.version, 1);
assert_eq!(&*decoded.session_token, "token");
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn backup_target_response_matches_decode() {
let spec = spec().await;
assert_operation_exists(spec, "/backup-target", "get");
let sample = json!({
"storage": "s3",
"bucket": "my-bucket",
"prefix": "",
"region": "ap-southeast-2",
"repo_password": "hunter2",
});
assert_valid(spec, &response_schema("/backup-target", "get"), &sample);
let decoded: BackupTarget = serde_json::from_value(sample).unwrap();
assert_eq!(decoded.bucket, "my-bucket");
assert_eq!(&*decoded.repo_password, "hunter2");
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn backup_report_request_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/backup-report", "post");
let instance = serde_json::to_value(
ReportArgs::builder()
.run_id("00000000-0000-0000-0000-000000000000".parse().unwrap())
.type_("tamanu-postgres".to_owned())
.purpose(BackupPurpose::Backup)
.outcome(RunOutcome::Success)
.bytes_uploaded(12345)
.snapshot_id("abc123".to_owned())
.s3_sent_raw_bytes(2_000_000)
.s3_sent_payload_bytes(1_950_000)
.s3_received_raw_bytes(4096)
.s3_received_payload_bytes(0)
.build(),
)
.unwrap();
assert_valid(spec, &request_schema("/backup-report", "post"), &instance);
let mut invalid = instance.clone();
invalid["outcome"] = json!("maybe");
let validator = validator_at(spec, &request_schema("/backup-report", "post"));
assert!(
!validator.is_valid(&invalid),
"spec accepted an invalid outcome; the validator is not checking refs",
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn status_response_carries_backup_now() {
let spec = spec().await;
let schema = resolve(spec, &response_schema("/status/{server_id}", "post"));
let backup_now = schema
.pointer("/properties/backup_now")
.or_else(|| {
schema
.get("allOf")?
.as_array()?
.iter()
.find_map(|member| member.pointer("/properties/backup_now"))
})
.unwrap_or_else(|| panic!("status response has no backup_now: {schema:#}"));
assert_eq!(
backup_now.pointer("/type"),
Some(&json!("array")),
"backup_now is no longer an array: {backup_now:#}",
);
assert_eq!(
backup_now.pointer("/items/type"),
Some(&json!("string")),
"backup_now items are no longer strings: {backup_now:#}",
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn status_response_carries_check_severities() {
let spec = spec().await;
let schema = resolve(spec, &response_schema("/status/{server_id}", "post"));
let check_severities = schema
.pointer("/properties/check_severities")
.or_else(|| {
schema
.get("allOf")?
.as_array()?
.iter()
.find_map(|member| member.pointer("/properties/check_severities"))
})
.unwrap_or_else(|| panic!("status response has no check_severities: {schema:#}"));
assert_eq!(
check_severities.pointer("/type"),
Some(&json!("object")),
"check_severities is no longer an object map: {check_severities:#}",
);
}
#[tokio::test]
#[ignore = "live canopy contract test; run by the dedicated CI job"]
async fn check_severities_endpoint_matches_spec() {
let spec = spec().await;
assert_operation_exists(spec, "/status/{server_id}/check-severities", "get");
let spec_levels: Vec<String> = resolve(spec, "/components/schemas/CheckSeverity")
.get("enum")
.and_then(Value::as_array)
.expect("CheckSeverity schema has an enum")
.iter()
.map(|v| {
v.as_str()
.expect("CheckSeverity enum values are strings")
.to_owned()
})
.collect();
use CheckSeverity::*;
const ALL: &[CheckSeverity] = &[Skip, Warn, Fail];
for severity in ALL {
match severity {
Skip | Warn | Fail => {}
}
}
for level in &spec_levels {
assert!(
serde_json::from_value::<CheckSeverity>(json!(level)).is_ok(),
"canopy CheckSeverity {level:?} does not deserialise into bestool's enum",
);
}
for severity in ALL {
let ours = serde_json::to_value(severity).unwrap();
let ours = ours.as_str().unwrap().to_owned();
assert!(
spec_levels.contains(&ours),
"bestool CheckSeverity {ours:?} is not accepted by canopy (spec has {spec_levels:?})",
);
}
}