Skip to main content

max / pom

Add CORS preflight monitoring to detect S3 browser upload failures Sends OPTIONS requests to configured endpoints to verify Access-Control-Allow-Origin and Access-Control-Allow-Methods headers. Alerts on failure/recovery with cooldown. Migration 8 (cors_checks table). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-28 19:14 UTC
Commit: 2572bc20b5394acbbe03afe673793d756b04f98f
Parent: ee37659
11 files changed, +558 insertions, -13 deletions
@@ -29,6 +29,11 @@ name = "git.makenot.work"
29 29 record_type = "A"
30 30 expected = []
31 31
32 + [[targets.mnw.cors]]
33 + url = "https://fsn1.your-objectstorage.com/makenotwork-files/cors-probe"
34 + origin = "https://makenot.work"
35 + method = "PUT"
36 +
32 37 [targets.mnw.whois]
33 38 domain = "makenot.work"
34 39 warn_days = 30
@@ -401,6 +401,70 @@ impl Alerter {
401 401 }
402 402
403 403 #[instrument(skip_all)]
404 + pub async fn send_cors_failure_alert(
405 + &self,
406 + target: &str,
407 + label: &str,
408 + failures: &[crate::types::CorsCheckResult],
409 + ) {
410 + let alert_key = format!("cors:{target}");
411 + if self.is_within_cooldown(&alert_key).await {
412 + info!("alert cooldown active for {alert_key}, skipping");
413 + return;
414 + }
415 +
416 + let n = failures.len();
417 + let subject = format!("[PoM] {label}: {n} CORS preflight(s) failing");
418 + let details: Vec<String> = failures
419 + .iter()
420 + .map(|f| {
421 + if let Some(ref err) = f.error {
422 + format!(" - {} {} from {}: {err}", f.method, f.url, f.origin)
423 + } else {
424 + format!(" - {} {} from {}: no CORS headers", f.method, f.url, f.origin)
425 + }
426 + })
427 + .collect();
428 + let body = format!(
429 + "Target: {label} ({target})\n\
430 + CORS preflight failures:\n{}\n\
431 + Instance: {}\n\
432 + Time: {}\n\n\
433 + Browser-side uploads will silently fail without CORS.\n\n\
434 + - PoM",
435 + details.join("\n"),
436 + self.instance_name,
437 + chrono::Utc::now().to_rfc3339(),
438 + );
439 +
440 + self.send_email(&subject, &body).await;
441 + self.record_alert(&alert_key, AlertCategory::CorsFailure, None, None, None).await;
442 + }
443 +
444 + #[instrument(skip_all)]
445 + pub async fn send_cors_recovery_alert(
446 + &self,
447 + target: &str,
448 + label: &str,
449 + ) {
450 + // No cooldown on recovery — always send
451 + let alert_key = format!("cors:{target}");
452 + let subject = format!("[PoM] {label}: CORS preflights recovered");
453 + let body = format!(
454 + "Target: {label} ({target})\n\
455 + All CORS preflight checks passing.\n\
456 + Instance: {}\n\
457 + Time: {}\n\n\
458 + - PoM",
459 + self.instance_name,
460 + chrono::Utc::now().to_rfc3339(),
461 + );
462 +
463 + self.send_email(&subject, &body).await;
464 + self.record_alert(&alert_key, AlertCategory::CorsRecovery, None, None, None).await;
465 + }
466 +
467 + #[instrument(skip_all)]
404 468 pub async fn send_latency_drift_alert(
405 469 &self,
406 470 target: &str,
@@ -0,0 +1,144 @@
1 + //! CORS preflight verification — sends OPTIONS requests and checks Access-Control headers.
2 +
3 + use tracing::instrument;
4 +
5 + use crate::config::CorsCheck;
6 + use crate::types::CorsCheckResult;
7 +
8 + /// Send a CORS preflight OPTIONS request and verify the response allows the expected origin.
9 + /// Returns one `CorsCheckResult` per `CorsCheck` in the input.
10 + #[instrument(skip_all)]
11 + pub async fn check_cors(target: &str, checks: &[CorsCheck]) -> Vec<CorsCheckResult> {
12 + let client = reqwest::Client::builder()
13 + .timeout(std::time::Duration::from_secs(10))
14 + .redirect(reqwest::redirect::Policy::none())
15 + .build()
16 + .unwrap();
17 +
18 + let mut results = Vec::with_capacity(checks.len());
19 + for check in checks {
20 + results.push(run_preflight(target, &client, check).await);
21 + }
22 + results
23 + }
24 +
25 + async fn run_preflight(
26 + target: &str,
27 + client: &reqwest::Client,
28 + check: &CorsCheck,
29 + ) -> CorsCheckResult {
30 + let now = chrono::Utc::now().to_rfc3339();
31 +
32 + let response = client
33 + .request(reqwest::Method::OPTIONS, &check.url)
34 + .header("Origin", &check.origin)
35 + .header("Access-Control-Request-Method", &check.method)
36 + .send()
37 + .await;
38 +
39 + match response {
40 + Ok(resp) => {
41 + let status = resp.status().as_u16();
42 + let allow_origin = resp
43 + .headers()
44 + .get("access-control-allow-origin")
45 + .and_then(|v| v.to_str().ok())
46 + .unwrap_or("")
47 + .to_string();
48 + let allow_methods = resp
49 + .headers()
50 + .get("access-control-allow-methods")
51 + .and_then(|v| v.to_str().ok())
52 + .unwrap_or("")
53 + .to_string();
54 +
55 + let origin_ok = allow_origin == check.origin || allow_origin == "*";
56 + let method_ok = allow_methods
57 + .split(',')
58 + .any(|m| m.trim().eq_ignore_ascii_case(&check.method));
59 +
60 + let passes = status < 400 && origin_ok && method_ok;
61 +
62 + let error = if passes {
63 + None
64 + } else {
65 + let mut reasons = Vec::new();
66 + if status >= 400 {
67 + reasons.push(format!("HTTP {status}"));
68 + }
69 + if !origin_ok {
70 + reasons.push(format!(
71 + "Access-Control-Allow-Origin: {allow_origin:?} (expected {:?} or \"*\")",
72 + check.origin
73 + ));
74 + }
75 + if !method_ok {
76 + reasons.push(format!(
77 + "Access-Control-Allow-Methods: {allow_methods:?} (expected {:?})",
78 + check.method
79 + ));
80 + }
81 + Some(reasons.join("; "))
82 + };
83 +
84 + CorsCheckResult {
85 + target: target.to_string(),
86 + url: check.url.clone(),
87 + origin: check.origin.clone(),
88 + method: check.method.clone(),
89 + passes,
90 + checked_at: now,
91 + error,
92 + }
93 + }
94 + Err(e) => CorsCheckResult {
95 + target: target.to_string(),
96 + url: check.url.clone(),
97 + origin: check.origin.clone(),
98 + method: check.method.clone(),
99 + passes: false,
100 + checked_at: now,
101 + error: Some(format!("preflight request failed: {e}")),
102 + },
103 + }
104 + }
105 +
106 + #[cfg(test)]
107 + mod tests {
108 + use super::*;
109 +
110 + #[test]
111 + fn cors_check_result_serde_roundtrip() {
112 + let result = CorsCheckResult {
113 + target: "mnw".to_string(),
114 + url: "https://s3.example.com/bucket/test".to_string(),
115 + origin: "https://makenot.work".to_string(),
116 + method: "PUT".to_string(),
117 + passes: true,
118 + checked_at: "2026-03-28T00:00:00Z".to_string(),
119 + error: None,
120 + };
121 + let json = serde_json::to_string(&result).unwrap();
122 + let parsed: CorsCheckResult = serde_json::from_str(&json).unwrap();
123 + assert_eq!(parsed.target, "mnw");
124 + assert!(parsed.passes);
125 + assert!(parsed.error.is_none());
126 + }
127 +
128 + #[test]
129 + fn cors_check_result_with_error() {
130 + let result = CorsCheckResult {
131 + target: "mnw".to_string(),
132 + url: "https://s3.example.com/bucket/test".to_string(),
133 + origin: "https://makenot.work".to_string(),
134 + method: "PUT".to_string(),
135 + passes: false,
136 + checked_at: "2026-03-28T00:00:00Z".to_string(),
137 + error: Some("HTTP 403".to_string()),
138 + };
139 + let json = serde_json::to_string(&result).unwrap();
140 + let parsed: CorsCheckResult = serde_json::from_str(&json).unwrap();
141 + assert!(!parsed.passes);
142 + assert_eq!(parsed.error.as_deref(), Some("HTTP 403"));
143 + }
144 + }
@@ -1,3 +1,4 @@
1 + pub mod cors;
1 2 pub mod dns;
2 3 pub mod http;
3 4 pub mod parse;
@@ -61,6 +61,7 @@ pub(crate) async fn cmd_serve(
61 61 handles.extend(tasks::spawn_route_tasks(config, pool, &token, &alerter));
62 62 handles.extend(tasks::spawn_dns_tasks(config, pool, &token, &alerter));
63 63 handles.extend(tasks::spawn_whois_tasks(config, pool, &token, &alerter));
64 + handles.extend(tasks::spawn_cors_tasks(config, pool, &token, &alerter));
64 65 handles.push(tasks::spawn_prune_task(pool, prune_days, &token));
65 66
66 67 // Spawn peer heartbeat tasks
@@ -0,0 +1,85 @@
1 + use tokio::task::JoinHandle;
2 + use tracing::info;
3 +
4 + use pom::alerts::Alerter;
5 + use pom::checks::cors;
6 + use pom::config::Config;
7 + use pom::db;
8 +
9 + pub(crate) fn spawn_cors_tasks(
10 + config: &Config,
11 + pool: &sqlx::SqlitePool,
12 + cancel: &tokio_util::sync::CancellationToken,
13 + alerter: &Option<Alerter>,
14 + ) -> Vec<JoinHandle<()>> {
15 + let interval_secs = config.serve.cors_check_interval_secs;
16 + let mut handles = Vec::new();
17 +
18 + for name in config.target_names() {
19 + let target_config = config.get_target(&name).unwrap().clone();
20 + if target_config.cors.is_empty() {
21 + continue;
22 + }
23 + let cors_checks = target_config.cors.clone();
24 + let label = target_config.label.clone();
25 + let pool = pool.clone();
26 + let alerter = alerter.clone();
27 + let cancel = cancel.clone();
28 + let n = cors_checks.len();
29 +
30 + info!("{name}: CORS check every {interval_secs}s ({n} endpoints)");
31 +
32 + handles.push(tokio::spawn(async move {
33 + let mut interval = tokio::time::interval(
34 + std::time::Duration::from_secs(interval_secs),
35 + );
36 + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
37 + let mut prev_failed: std::collections::HashSet<String> = std::collections::HashSet::new();
38 +
39 + interval.tick().await; // consume immediate first tick
40 + loop {
41 + tokio::select! {
42 + _ = cancel.cancelled() => break,
43 + _ = interval.tick() => {}
44 + }
45 + let results = cors::check_cors(&name, &cors_checks).await;
46 +
47 + for result in &results {
48 + if let Err(e) = db::insert_cors_check(&pool, result).await {
49 + tracing::error!("{}: failed to store CORS check for {}: {e}", name, result.url);
50 + }
51 + }
52 +
53 + let current_failed: std::collections::HashSet<String> = results
54 + .iter()
55 + .filter(|r| !r.passes)
56 + .map(|r| r.url.clone())
57 + .collect();
58 +
59 + let ok_count = results.iter().filter(|r| r.passes).count();
60 + info!("{name}: CORS {ok_count}/{n} pass");
61 +
62 + if let Some(ref alerter) = alerter {
63 + // New failures
64 + let new_failures: Vec<&pom::types::CorsCheckResult> = results
65 + .iter()
66 + .filter(|r| !r.passes && !prev_failed.contains(&r.url))
67 + .collect();
68 + if !new_failures.is_empty() {
69 + let owned: Vec<pom::types::CorsCheckResult> = new_failures.into_iter().cloned().collect();
70 + alerter.send_cors_failure_alert(&name, &label, &owned).await;
71 + }
72 +
73 + // All recovered
74 + if !prev_failed.is_empty() && current_failed.is_empty() {
75 + alerter.send_cors_recovery_alert(&name, &label).await;
76 + }
77 + }
78 +
79 + prev_failed = current_failed;
80 + }
81 + }));
82 + }
83 +
84 + handles
85 + }
@@ -1,3 +1,4 @@
1 + mod cors;
1 2 mod dns;
2 3 mod health;
3 4 mod meta_alert;
@@ -6,6 +7,7 @@ mod routes;
6 7 mod tls;
7 8 mod whois;
8 9
10 + pub(crate) use cors::spawn_cors_tasks;
9 11 pub(crate) use dns::spawn_dns_tasks;
10 12 pub(crate) use health::spawn_health_tasks;
11 13 pub(crate) use meta_alert::spawn_meta_alert_task;
@@ -85,6 +85,9 @@ pub struct ServeConfig {
85 85 /// Seconds between DNS record verification checks.
86 86 #[serde(default = "default_dns_check_interval")]
87 87 pub dns_check_interval_secs: u64,
88 + /// Seconds between CORS preflight verification checks.
89 + #[serde(default = "default_cors_check_interval")]
90 + pub cors_check_interval_secs: u64,
88 91 /// Bearer token required for API access. If set, all /api/* requests must
89 92 /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var.
90 93 pub api_token: Option<String>,
@@ -103,6 +106,7 @@ impl Default for ServeConfig {
103 106 tls_check_interval_secs: 3600,
104 107 route_check_interval_secs: 300,
105 108 dns_check_interval_secs: 3600,
109 + cors_check_interval_secs: 3600,
106 110 api_token: None,
107 111 dashboard: false,
108 112 }
@@ -129,6 +133,11 @@ fn default_dns_check_interval() -> u64 {
129 133 3600
130 134 }
131 135
136 + fn default_cors_check_interval() -> u64 {
137 + // 1 hour: CORS policies change infrequently
138 + 3600
139 + }
140 +
132 141 fn default_serve_interval() -> u64 {
133 142 // 5 minutes: frequent enough to catch outages within an SLA window,
134 143 // infrequent enough to avoid noise
@@ -163,6 +172,9 @@ pub struct TargetConfig {
163 172 pub dns: Vec<DnsRecord>,
164 173 /// WHOIS domain expiry monitoring. `None` disables WHOIS checks.
165 174 pub whois: Option<WhoisConfig>,
175 + /// CORS preflight checks. Empty disables CORS checks.
176 + #[serde(default)]
177 + pub cors: Vec<CorsCheck>,
166 178 }
167 179
168 180 #[derive(Debug, Clone, Deserialize)]
@@ -189,6 +201,21 @@ fn default_whois_warn_days() -> u32 {
189 201 }
190 202
191 203 #[derive(Debug, Clone, Deserialize)]
204 + pub struct CorsCheck {
205 + /// URL to send the preflight OPTIONS request to.
206 + pub url: String,
207 + /// Expected `Access-Control-Allow-Origin` value.
208 + pub origin: String,
209 + /// HTTP method to include in `Access-Control-Request-Method`.
210 + #[serde(default = "default_cors_method")]
211 + pub method: String,
212 + }
213 +
214 + fn default_cors_method() -> String {
215 + "PUT".to_string()
216 + }
217 +
218 + #[derive(Debug, Clone, Deserialize)]
192 219 pub struct TlsConfig {
193 220 /// Hostname to connect to for the TLS check.
194 221 pub host: String,
M src/db.rs +68 -1
@@ -11,7 +11,7 @@ use std::str::FromStr;
11 11 use tracing::{info, instrument};
12 12
13 13 use crate::error::Result;
14 - use crate::types::{DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestSummary, TlsStatus, WhoisResult};
14 + use crate::types::{CorsCheckResult, DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestSummary, TlsStatus, WhoisResult};
15 15
16 16 /// Each migration is a (version, description, SQL) tuple. Versions start at 1.
17 17 /// The SQL may contain multiple statements separated by semicolons.
@@ -147,6 +147,19 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
147 147 CREATE INDEX IF NOT EXISTS idx_test_details_run_id ON test_details(run_id);
148 148 CREATE INDEX IF NOT EXISTS idx_test_details_name ON test_details(test_name, run_id DESC);
149 149 "#),
150 + (8, "add cors_checks table", r#"
151 + CREATE TABLE IF NOT EXISTS cors_checks (
152 + id INTEGER PRIMARY KEY AUTOINCREMENT,
153 + target TEXT NOT NULL,
154 + url TEXT NOT NULL,
155 + origin TEXT NOT NULL,
156 + method TEXT NOT NULL,
157 + passes INTEGER NOT NULL,
158 + checked_at TEXT NOT NULL,
159 + error TEXT
160 + );
161 + CREATE INDEX IF NOT EXISTS idx_cors_target_id ON cors_checks(target, id DESC);
162 + "#),
150 163 ];
151 164
152 165 #[instrument(skip_all)]
@@ -966,6 +979,60 @@ pub async fn get_latest_whois_check(
966 979 .await?)
967 980 }
968 981
982 + // --- CORS check queries ---
983 +
984 + #[instrument(skip_all)]
985 + pub async fn insert_cors_check(
986 + pool: &SqlitePool,
987 + result: &CorsCheckResult,
988 + ) -> Result<i64> {
989 + let row = sqlx::query(
990 + "INSERT INTO cors_checks (target, url, origin, method, passes, checked_at, error)
991 + VALUES (?, ?, ?, ?, ?, ?, ?)",
992 + )
993 + .bind(&result.target)
994 + .bind(&result.url)
995 + .bind(&result.origin)
996 + .bind(&result.method)
997 + .bind(result.passes)
998 + .bind(&result.checked_at)
999 + .bind(&result.error)
1000 + .execute(pool)
1001 + .await?;
1002 + Ok(row.last_insert_rowid())
1003 + }
1004 +
1005 + /// Get the latest CORS check per URL for a target.
1006 + #[instrument(skip_all)]
1007 + pub async fn get_latest_cors_checks(
1008 + pool: &SqlitePool,
1009 + target: &str,
1010 + ) -> Result<Vec<CorsCheckResult>> {
1011 + let rows = sqlx::query_as::<_, (String, String, String, String, bool, String, Option<String>)>(
1012 + "SELECT c.target, c.url, c.origin, c.method, c.passes, c.checked_at, c.error
1013 + FROM cors_checks c
1014 + INNER JOIN (SELECT url, MAX(id) as max_id FROM cors_checks WHERE target = ? GROUP BY url) latest
1015 + ON c.id = latest.max_id
1016 + ORDER BY c.url",
1017 + )
1018 + .bind(target)
1019 + .fetch_all(pool)
1020 + .await?;
1021 +
1022 + Ok(rows
1023 + .into_iter()
1024 + .map(|(target, url, origin, method, passes, checked_at, error)| CorsCheckResult {
1025 + target,
1026 + url,
1027 + origin,
1028 + method,
1029 + passes,
1030 + checked_at,
1031 + error,
1032 + })
1033 + .collect())
1034 + }
1035 +
969 1036 // --- Stale data cleanup ---
970 1037
971 1038 /// Delete route_checks for paths no longer in the config.
M src/types.rs +26
@@ -25,6 +25,8 @@ pub enum AlertCategory {
25 25 LatencyDrift,
26 26 LatencyRecovery,
27 27 TestDurationDrift,
28 + CorsFailure,
29 + CorsRecovery,
28 30 MonitoringOffline,
29 31 MonitoringRecovery,
30 32 }
@@ -48,6 +50,8 @@ impl fmt::Display for AlertCategory {
48 50 Self::LatencyDrift => write!(f, "latency_drift"),
49 51 Self::LatencyRecovery => write!(f, "latency_recovery"),
50 52 Self::TestDurationDrift => write!(f, "test_duration_drift"),
53 + Self::CorsFailure => write!(f, "cors_failure"),
54 + Self::CorsRecovery => write!(f, "cors_recovery"),
51 55 Self::MonitoringOffline => write!(f, "monitoring_offline"),
52 56 Self::MonitoringRecovery => write!(f, "monitoring_recovery"),
53 57 }
@@ -74,6 +78,8 @@ impl std::str::FromStr for AlertCategory {
74 78 "latency_drift" => Ok(Self::LatencyDrift),
75 79 "latency_recovery" => Ok(Self::LatencyRecovery),
76 80 "test_duration_drift" => Ok(Self::TestDurationDrift),
81 + "cors_failure" => Ok(Self::CorsFailure),
82 + "cors_recovery" => Ok(Self::CorsRecovery),
77 83 "monitoring_offline" => Ok(Self::MonitoringOffline),
78 84 "monitoring_recovery" => Ok(Self::MonitoringRecovery),
79 85 other => Err(format!("unknown alert category: {other}")),
@@ -379,6 +385,24 @@ pub struct WhoisResult {
379 385 pub error: Option<String>,
380 386 }
381 387
388 + #[derive(Debug, Clone, Serialize, Deserialize)]
389 + pub struct CorsCheckResult {
390 + /// Config key identifying the monitored target.
391 + pub target: String,
392 + /// URL that was checked.
393 + pub url: String,
394 + /// Origin sent in the preflight request.
395 + pub origin: String,
396 + /// HTTP method sent in `Access-Control-Request-Method`.
397 + pub method: String,
398 + /// Whether the preflight response allows the origin and method.
399 + pub passes: bool,
400 + /// When this check was performed, in RFC 3339 format (UTC).
401 + pub checked_at: String,
402 + /// Error message if the preflight request failed.
403 + pub error: Option<String>,
404 + }
405 +
382 406 impl LatencyStats {
383 407 /// Compute latency statistics from a slice of response times.
384 408 /// Returns `None` if the slice is empty.
@@ -606,6 +630,8 @@ mod tests {
606 630 AlertCategory::LatencyDrift,
607 631 AlertCategory::LatencyRecovery,
608 632 AlertCategory::TestDurationDrift,
633 + AlertCategory::CorsFailure,
634 + AlertCategory::CorsRecovery,
609 635 AlertCategory::MonitoringOffline,
610 636 AlertCategory::MonitoringRecovery,
611 637 ] {
@@ -376,7 +376,7 @@ async fn migration_fresh_db_reaches_latest_version() {
376 376 // A fresh in-memory DB should run all migrations and reach the latest version.
377 377 let pool = db::connect_in_memory().await.unwrap();
378 378 let version = db::get_schema_version(&pool).await.unwrap();
379 - assert_eq!(version, 7);
379 + assert_eq!(version, 8);
380 380
381 381 // Verify the schema_version table has entries for each migration
382 382 let rows = sqlx::query_as::<_, (i64, String)>(
@@ -385,7 +385,7 @@ async fn migration_fresh_db_reaches_latest_version() {
385 385 .fetch_all(&pool)
386 386 .await
387 387 .unwrap();
388 - assert_eq!(rows.len(), 7);
388 + assert_eq!(rows.len(), 8);
389 389 assert_eq!(rows[0].0, 1);
390 390 assert_eq!(rows[0].1, "initial schema");
391 391 assert_eq!(rows[1].0, 2);
@@ -400,6 +400,8 @@ async fn migration_fresh_db_reaches_latest_version() {
400 400 assert_eq!(rows[5].1, "add dns_checks and whois_checks tables");
401 401 assert_eq!(rows[6].0, 7);
402 402 assert_eq!(rows[6].1, "add test_details table");
403 + assert_eq!(rows[7].0, 8);
404 + assert_eq!(rows[7].1, "add cors_checks table");
403 405
404 406 // Verify actual tables were created by inserting data
405 407 let snapshot = HealthSnapshot {
@@ -419,18 +421,18 @@ async fn migration_fresh_db_reaches_latest_version() {
419 421 async fn migration_already_current_is_idempotent() {
420 422 // Running migrations on an already-migrated DB should be a no-op.
421 423 let pool = db::connect_in_memory().await.unwrap();
422 - assert_eq!(db::get_schema_version(&pool).await.unwrap(), 7);
424 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 8);
423 425
424 426 // Run migrations again
425 427 db::run_migrations(&pool).await.unwrap();
426 - assert_eq!(db::get_schema_version(&pool).await.unwrap(), 7);
428 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 8);
427 429
428 - // schema_version should still have exactly seven entries (not duplicated)
430 + // schema_version should still have exactly eight entries (not duplicated)
429 431 let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version")
430 432 .fetch_one(&pool)
431 433 .await
432 434 .unwrap();
433 - assert_eq!(count.0, 7);
435 + assert_eq!(count.0, 8);
434 436 }
435 437
436 438 #[tokio::test]
@@ -490,7 +492,7 @@ async fn migration_detects_pre_migration_database() {
490 492 db::run_migrations(&pool).await.unwrap();
491 493
492 494 // Version should be 6 (stamped v1 + ran v2 + ran v3 + ran v4 + ran v5 + ran v6)
493 - assert_eq!(db::get_schema_version(&pool).await.unwrap(), 7);
495 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 8);
494 496
495 497 // Description should indicate pre-existing
496 498 let row = sqlx::query_as::<_, (String,)>(
@@ -791,7 +793,7 @@ async fn tool_run_tests_no_test_config() {
791 793 async fn migration_v2_creates_alerts_table() {
792 794 let pool = db::connect_in_memory().await.unwrap();
793 795 let version = db::get_schema_version(&pool).await.unwrap();
794 - assert_eq!(version, 7);
796 + assert_eq!(version, 8);
795 797
796 798 // Verify alerts table exists by inserting
797 799 let id = db::insert_alert(&pool, "mnw", "health", Some("operational"), Some("error"), None)
@@ -859,7 +861,7 @@ async fn prune_removes_old_alerts() {
859 861 async fn migration_v3_creates_tls_checks_table() {
860 862 let pool = db::connect_in_memory().await.unwrap();
861 863 let version = db::get_schema_version(&pool).await.unwrap();
862 - assert_eq!(version, 7);
864 + assert_eq!(version, 8);
863 865
864 866 // Verify tls_checks table exists by inserting
865 867 let status = pom::types::TlsStatus {
@@ -1074,7 +1076,7 @@ cooldown_secs = 120
1074 1076 async fn migration_v4_creates_incidents_table() {
1075 1077 let pool = db::connect_in_memory().await.unwrap();
1076 1078 let version = db::get_schema_version(&pool).await.unwrap();
1077 - assert_eq!(version, 7);
1079 + assert_eq!(version, 8);
1078 1080
1079 1081 // Verify incidents table exists by inserting
1080 1082 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
@@ -1203,7 +1205,7 @@ async fn api_status_no_incidents_omits_fields() {
1203 1205 async fn migration_v5_creates_route_checks_table() {
1204 1206 let pool = db::connect_in_memory().await.unwrap();
1205 1207 let version = db::get_schema_version(&pool).await.unwrap();
1206 - assert_eq!(version, 7);
1208 + assert_eq!(version, 8);
1207 1209
1208 1210 // Verify route_checks table exists by inserting
1209 1211 let result = pom::checks::routes::RouteCheckResult {
@@ -2203,7 +2205,7 @@ async fn peer_uuid_mismatch_updates_db_identity() {
2203 2205 async fn migration_v6_creates_dns_and_whois_tables() {
2204 2206 let pool = db::connect_in_memory().await.unwrap();
2205 2207 let version = db::get_schema_version(&pool).await.unwrap();
2206 - assert_eq!(version, 7);
2208 + assert_eq!(version, 8);
2207 2209
2208 2210 // Verify dns_checks table exists
2209 2211 let dns_result = DnsCheckResult {
@@ -2442,6 +2444,91 @@ async fn whois_check_error_stored() {
2442 2444 }
2443 2445
2444 2446 #[tokio::test]
2447 + async fn cors_check_insert_and_query() {
2448 + let pool = db::connect_in_memory().await.unwrap();
2449 +
2450 + let result = CorsCheckResult {
2451 + target: "mnw".to_string(),
2452 + url: "https://storage.example.com/bucket/probe".to_string(),
2453 + origin: "https://makenot.work".to_string(),
2454 + method: "PUT".to_string(),
2455 + passes: true,
2456 + checked_at: chrono::Utc::now().to_rfc3339(),
2457 + error: None,
2458 + };
2459 + db::insert_cors_check(&pool, &result).await.unwrap();
2460 +
2461 + let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2462 + assert_eq!(latest.len(), 1);
2463 + assert!(latest[0].passes);
2464 + assert_eq!(latest[0].url, "https://storage.example.com/bucket/probe");
2465 + }
2466 +
2467 + #[tokio::test]
2468 + async fn cors_check_latest_per_url() {
2469 + let pool = db::connect_in_memory().await.unwrap();
2470 +
2471 + // Insert an old failing check
2472 + let r1 = CorsCheckResult {
2473 + target: "mnw".to_string(),
2474 + url: "https://storage.example.com/bucket/probe".to_string(),
2475 + origin: "https://makenot.work".to_string(),
2476 + method: "PUT".to_string(),
2477 + passes: false,
2478 + checked_at: "2026-03-01T00:00:00Z".to_string(),
2479 + error: Some("Missing Access-Control-Allow-Origin".to_string()),
2480 + };
2481 + db::insert_cors_check(&pool, &r1).await.unwrap();
2482 +
2483 + // Insert a newer passing check for same URL
2484 + let r2 = CorsCheckResult {
2485 + target: "mnw".to_string(),
2486 + url: "https://storage.example.com/bucket/probe".to_string(),
2487 + origin: "https://makenot.work".to_string(),
2488 + method: "PUT".to_string(),
2489 + passes: true,
2490 + checked_at: "2026-03-15T00:00:00Z".to_string(),
2491 + error: None,
2492 + };
2493 + db::insert_cors_check(&pool, &r2).await.unwrap();
2494 +
2495 + let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2496 + // Should return only the latest (passing) check per URL
2497 + assert_eq!(latest.len(), 1);
2498 + assert!(latest[0].passes);
2499 + }
2500 +
2501 + #[tokio::test]
2502 + async fn cors_check_filters_by_target() {
2503 + let pool = db::connect_in_memory().await.unwrap();
2504 +
2505 + let r1 = CorsCheckResult {
2506 + target: "mnw".to_string(),
2507 + url: "https://storage.example.com/bucket/probe".to_string(),
2508 + origin: "https://makenot.work".to_string(),
2509 + method: "PUT".to_string(),
2510 + passes: true,
2511 + checked_at: chrono::Utc::now().to_rfc3339(),
2512 + error: None,
2513 + };
2514 + let r2 = CorsCheckResult {
2515 + target: "other".to_string(),
2516 + url: "https://other.example.com/probe".to_string(),
2517 + origin: "https://other.app".to_string(),
2518 + method: "PUT".to_string(),
2519 + passes: false,
2520 + checked_at: chrono::Utc::now().to_rfc3339(),
2521 + error: Some("Failed".to_string()),
2522 + };
2523 + db::insert_cors_check(&pool, &r1).await.unwrap();
2524 + db::insert_cors_check(&pool, &r2).await.unwrap();
2525 +
2526 + let mnw_checks = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2527 + assert_eq!(mnw_checks.len(), 1);
2528 + assert_eq!(mnw_checks[0].target, "mnw");
2529 + }
2530 +
2531 + #[tokio::test]
2445 2532 async fn prune_removes_old_dns_checks() {
2446 2533 let pool = db::connect_in_memory().await.unwrap();
2447 2534
@@ -2657,6 +2744,42 @@ domain = "makenot.work"
2657 2744 }
2658 2745
2659 2746 #[test]
2747 + fn config_with_cors_parses() {
2748 + let toml_str = r#"
2749 + [targets.mnw]
2750 + label = "MakeNotWork"
2751 +
2752 + [[targets.mnw.cors]]
2753 + url = "https://example.com/bucket/probe"
2754 + origin = "https://myapp.com"
2755 + method = "PUT"
2756 +
2757 + [[targets.mnw.cors]]
2758 + url = "https://example.com/bucket/probe2"
2759 + origin = "https://myapp.com"
2760 + "#;
2761 + let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2762 + let mnw = config.get_target("mnw").unwrap();
2763 + assert_eq!(mnw.cors.len(), 2);
2764 + assert_eq!(mnw.cors[0].url, "https://example.com/bucket/probe");
2765 + assert_eq!(mnw.cors[0].origin, "https://myapp.com");
2766 + assert_eq!(mnw.cors[0].method, "PUT");
2767 + // Second entry uses default method
2768 + assert_eq!(mnw.cors[1].method, "PUT");
2769 + }
2770 +
2771 + #[test]
2772 + fn config_no_cors_defaults_to_empty() {
2773 + let toml_str = r#"
2774 + [targets.mnw]
2775 + label = "MakeNotWork"
2776 + "#;
2777 + let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2778 + let mnw = config.get_target("mnw").unwrap();
2779 + assert!(mnw.cors.is_empty());
2780 + }
2781 +
2782 + #[test]
2660 2783 fn config_no_dns_defaults_to_empty() {
2661 2784 let toml_str = r#"
2662 2785 [targets.mnw]