Skip to main content

max / makenotwork

138.5 KB · 4512 lines History Blame Raw
1 use std::collections::HashMap;
2 use std::str::FromStr;
3
4 use axum::body::Body;
5 use http_body_util::BodyExt;
6 use tower::ServiceExt;
7
8 use pom::db;
9 use pom::tools::PomServer;
10 use pom::types::*;
11
12 #[tokio::test]
13 async fn health_check_insert_and_query() {
14 let pool = db::connect_in_memory().await.unwrap();
15
16 let snapshot = HealthSnapshot {
17 id: None,
18 target: "test-target".to_string(),
19 status: HealthStatus::Operational,
20 checked_at: "2026-03-10T00:00:00Z".to_string(),
21 response_time_ms: 150,
22 details: Some(HealthDetails {
23 version: Some("1.0.0".to_string()),
24 git_sha: None,
25 uptime: Some("5h 30m".to_string()),
26 checks: None,
27 monitoring: None,
28 }),
29 error: None,
30 };
31
32 let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
33 assert!(id > 0);
34
35 let latest = db::get_latest_health(&pool, "test-target").await.unwrap();
36 assert!(latest.is_some());
37 let latest = latest.unwrap();
38 assert_eq!(latest.status, HealthStatus::Operational);
39 assert_eq!(latest.response_time_ms, 150);
40 assert_eq!(latest.details.unwrap().version.unwrap(), "1.0.0");
41 }
42
43 #[tokio::test]
44 async fn health_history_returns_ordered() {
45 let pool = db::connect_in_memory().await.unwrap();
46
47 for i in 0..5 {
48 let snapshot = HealthSnapshot {
49 id: None,
50 target: "mnw".to_string(),
51 status: HealthStatus::Operational,
52 checked_at: format!("2026-03-10T0{i}:00:00Z"),
53 response_time_ms: 100 + i * 10,
54 details: None,
55 error: None,
56 };
57 db::insert_health_check(&pool, &snapshot).await.unwrap();
58 }
59
60 let history = db::get_health_history(&pool, Some("mnw"), 3).await.unwrap();
61 assert_eq!(history.len(), 3);
62 // Most recent first (DESC)
63 assert!(history[0].response_time_ms > history[1].response_time_ms);
64 }
65
66 #[tokio::test]
67 async fn health_history_filters_by_target() {
68 let pool = db::connect_in_memory().await.unwrap();
69
70 for target in &["alpha", "beta"] {
71 let snapshot = HealthSnapshot {
72 id: None,
73 target: target.to_string(),
74 status: HealthStatus::Operational,
75 checked_at: "2026-03-10T00:00:00Z".to_string(),
76 response_time_ms: 100,
77 details: None,
78 error: None,
79 };
80 db::insert_health_check(&pool, &snapshot).await.unwrap();
81 }
82
83 let all = db::get_health_history(&pool, None, 10).await.unwrap();
84 assert_eq!(all.len(), 2);
85
86 let alpha_only = db::get_health_history(&pool, Some("alpha"), 10)
87 .await
88 .unwrap();
89 assert_eq!(alpha_only.len(), 1);
90 assert_eq!(alpha_only[0].target, "alpha");
91 }
92
93 #[tokio::test]
94 async fn test_run_insert_and_query() {
95 let pool = db::connect_in_memory().await.unwrap();
96
97 let run = TestRun {
98 id: None,
99 target: "mnw".to_string(),
100 started_at: "2026-03-10T00:00:00Z".to_string(),
101 finished_at: Some("2026-03-10T00:02:00Z".to_string()),
102 duration_secs: Some(120),
103 exit_code: Some(0),
104 passed: true,
105 summary: TestSummary {
106 steps: vec![
107 StepResult {
108 name: "cargo check".to_string(),
109 passed: true,
110 },
111 StepResult {
112 name: "cargo test --lib".to_string(),
113 passed: true,
114 },
115 ],
116 total_passed: Some(759),
117 total_failed: Some(0),
118 details: vec![],
119 },
120 raw_output: "test output here".to_string(),
121 filter: None,
122 };
123
124 let id = db::insert_test_run(&pool, &run).await.unwrap();
125 assert!(id.0 > 0);
126
127 let latest = db::get_latest_test_run(&pool, "mnw").await.unwrap();
128 assert!(latest.is_some());
129 let latest = latest.unwrap();
130 assert!(latest.passed);
131 assert_eq!(latest.summary.total_passed, Some(759));
132 assert_eq!(latest.summary.steps.len(), 2);
133 assert_eq!(latest.raw_output, "test output here");
134 }
135
136 #[tokio::test]
137 async fn test_history_excludes_other_targets() {
138 let pool = db::connect_in_memory().await.unwrap();
139
140 for target in &["mnw", "other"] {
141 let run = TestRun {
142 id: None,
143 target: target.to_string(),
144 started_at: "2026-03-10T00:00:00Z".to_string(),
145 finished_at: None,
146 duration_secs: None,
147 exit_code: None,
148 passed: true,
149 summary: TestSummary {
150 steps: vec![],
151 total_passed: None,
152 total_failed: None,
153 details: vec![],
154 },
155 raw_output: String::new(),
156 filter: None,
157 };
158 db::insert_test_run(&pool, &run).await.unwrap();
159 }
160
161 let mnw_only = db::get_test_history(&pool, Some("mnw"), 10).await.unwrap();
162 assert_eq!(mnw_only.len(), 1);
163 }
164
165 #[tokio::test]
166 async fn prune_removes_old_records() {
167 let pool = db::connect_in_memory().await.unwrap();
168
169 // Insert an old health check (60 days ago)
170 let old = HealthSnapshot {
171 id: None,
172 target: "mnw".to_string(),
173 status: HealthStatus::Operational,
174 checked_at: (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339(),
175 response_time_ms: 100,
176 details: None,
177 error: None,
178 };
179 db::insert_health_check(&pool, &old).await.unwrap();
180
181 // Insert a recent one
182 let recent = HealthSnapshot {
183 id: None,
184 target: "mnw".to_string(),
185 status: HealthStatus::Operational,
186 checked_at: chrono::Utc::now().to_rfc3339(),
187 response_time_ms: 100,
188 details: None,
189 error: None,
190 };
191 db::insert_health_check(&pool, &recent).await.unwrap();
192
193 let result = db::prune_old_records(&pool, 30).await.unwrap();
194 assert_eq!(result.health, 1);
195
196 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
197 assert_eq!(remaining.len(), 1);
198 }
199
200 #[tokio::test]
201 async fn parse_ci_output_integration() {
202 use pom::checks::parse;
203
204 let output = r"
205 ========================================
206 cargo check
207 ========================================
208
209 Finished `dev` profile
210
211 ========================================
212 cargo test --lib
213 ========================================
214
215 running 45 tests
216 test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s
217
218 ========================================
219 CI Summary
220 ========================================
221
222 PASS cargo check
223 PASS cargo test --lib
224 PASS cargo clippy
225
226 All steps passed.
227 ";
228
229 let summary = parse::parse_ci_output(output);
230 assert_eq!(summary.steps.len(), 3);
231 assert!(summary.steps.iter().all(|s| s.passed));
232 assert_eq!(summary.total_passed, Some(45));
233 assert_eq!(summary.total_failed, Some(0));
234 }
235
236 #[tokio::test]
237 async fn peer_identity_first_wins() {
238 let pool = db::connect_in_memory().await.unwrap();
239
240 db::store_peer_identity(&pool, "astra", "uuid-1")
241 .await
242 .unwrap();
243 // Second insert with different ID should be ignored (INSERT OR IGNORE)
244 db::store_peer_identity(&pool, "astra", "uuid-2")
245 .await
246 .unwrap();
247
248 let stored = db::get_peer_identity(&pool, "astra").await.unwrap();
249 assert_eq!(stored, Some("uuid-1".to_string()));
250 }
251
252 #[tokio::test]
253 async fn peer_heartbeat_insert_and_query() {
254 let pool = db::connect_in_memory().await.unwrap();
255
256 db::insert_peer_heartbeat(&pool, "astra", "online", 42)
257 .await
258 .unwrap();
259 db::insert_peer_heartbeat(&pool, "astra", "online", 55)
260 .await
261 .unwrap();
262 db::insert_peer_heartbeat(&pool, "astra", "missing", 0)
263 .await
264 .unwrap();
265
266 let history = db::get_peer_heartbeat_history(&pool, "astra", 10)
267 .await
268 .unwrap();
269 assert_eq!(history.len(), 3);
270 // Most recent first
271 assert_eq!(history[0].status, "missing");
272 assert_eq!(history[1].latency_ms, 55);
273 }
274
275 // API endpoint tests
276
277 fn test_config() -> pom::config::Config {
278 toml::from_str(
279 r#"
280 [targets.mnw]
281 label = "MakeNotWork"
282 [targets.mnw.health]
283 url = "https://makenot.work/health"
284 "#,
285 )
286 .unwrap()
287 }
288
289 /// Build a GET request carrying a `ConnectInfo<SocketAddr>` extension, the real
290 /// server injects it via `into_make_service_with_connect_info`, but `oneshot`
291 /// does not, and the per-IP rate-limit layer extracts it.
292 fn get_req(path: &str) -> axum::http::Request<Body> {
293 let mut req = axum::http::Request::builder()
294 .uri(path)
295 .body(Body::empty())
296 .unwrap();
297 req.extensions_mut()
298 .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
299 [127, 0, 0, 1],
300 41000,
301 ))));
302 req
303 }
304
305 /// GET a path and return (status_code, body_string), for HTML responses.
306 async fn get_body(app: &axum::Router, path: &str) -> (u16, String) {
307 let resp = app.clone().oneshot(get_req(path)).await.unwrap();
308 let status = resp.status().as_u16();
309 let body = resp.into_body().collect().await.unwrap().to_bytes();
310 (status, String::from_utf8_lossy(&body).into_owned())
311 }
312
313 fn test_mesh() -> pom::peer::SharedMeshState {
314 let info = pom::peer::InstanceInfo {
315 id: "test-uuid".to_string(),
316 name: "test-node".to_string(),
317 version: "0.1.0".to_string(),
318 targets: vec!["mnw".to_string()],
319 started_at: "2026-03-10T00:00:00Z".to_string(),
320 };
321 pom::peer::new_mesh_state(info, &HashMap::new())
322 }
323
324 async fn api_get(app: &axum::Router, path: &str) -> (u16, serde_json::Value) {
325 let resp = app.clone().oneshot(get_req(path)).await.unwrap();
326 let status = resp.status().as_u16();
327 let body = resp.into_body().collect().await.unwrap().to_bytes();
328 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
329 (status, json)
330 }
331
332 #[tokio::test]
333 async fn api_status_returns_targets() {
334 let pool = db::connect_in_memory().await.unwrap();
335 let config = test_config();
336 let app = pom::api::router(pool.clone(), config, None);
337
338 // Insert a health check so there's data
339 let snapshot = HealthSnapshot {
340 id: None,
341 target: "mnw".to_string(),
342 status: HealthStatus::Operational,
343 checked_at: "2026-03-10T00:00:00Z".to_string(),
344 response_time_ms: 120,
345 details: None,
346 error: None,
347 };
348 db::insert_health_check(&pool, &snapshot).await.unwrap();
349
350 let (status, json) = api_get(&app, "/api/status").await;
351 assert_eq!(status, 200);
352 assert!(json["targets"]["mnw"].is_object());
353 assert_eq!(json["targets"]["mnw"]["label"], "MakeNotWork");
354 assert_eq!(json["targets"]["mnw"]["latest"]["status"], "operational");
355 assert_eq!(json["targets"]["mnw"]["latest"]["response_time_ms"], 120);
356 }
357
358 #[tokio::test]
359 async fn api_status_target_not_found() {
360 let pool = db::connect_in_memory().await.unwrap();
361 let config = test_config();
362 let app = pom::api::router(pool, config, None);
363
364 let (status, json) = api_get(&app, "/api/status/nonexistent").await;
365 assert_eq!(status, 404);
366 assert!(json["error"].as_str().unwrap().contains("unknown target"));
367 }
368
369 #[tokio::test]
370 async fn api_peer_info_returns_instance() {
371 let pool = db::connect_in_memory().await.unwrap();
372 let config = test_config();
373 let mesh = test_mesh();
374 let app = pom::api::router(pool, config, Some(mesh));
375
376 let (status, json) = api_get(&app, "/api/peer/info").await;
377 assert_eq!(status, 200);
378 assert_eq!(json["id"], "test-uuid");
379 assert_eq!(json["name"], "test-node");
380 }
381
382 #[tokio::test]
383 async fn api_peer_info_disabled_without_mesh() {
384 let pool = db::connect_in_memory().await.unwrap();
385 let config = test_config();
386 let app = pom::api::router(pool, config, None);
387
388 let (status, json) = api_get(&app, "/api/peer/info").await;
389 assert_eq!(status, 503);
390 assert!(json["error"].as_str().unwrap().contains("not enabled"));
391 }
392
393 #[tokio::test]
394 async fn api_mesh_view_includes_self() {
395 let pool = db::connect_in_memory().await.unwrap();
396 let config = test_config();
397 let mesh = test_mesh();
398 let app = pom::api::router(pool, config, Some(mesh));
399
400 let (status, json) = api_get(&app, "/api/mesh").await;
401 assert_eq!(status, 200);
402 assert!(json["instances"]["test-node"].is_object());
403 assert_eq!(
404 json["instances"]["test-node"]["instance"]["id"],
405 "test-uuid"
406 );
407 }
408
409 // Migration tests
410
411 #[tokio::test]
412 async fn migration_fresh_db_reaches_latest_version() {
413 // A fresh in-memory DB should run all migrations and reach the latest version.
414 let pool = db::connect_in_memory().await.unwrap();
415 let version = db::get_schema_version(&pool).await.unwrap();
416 assert_eq!(version, 15);
417
418 // Verify the schema_version table has entries for each migration
419 let rows = sqlx::query_as::<_, (i64, String)>(
420 "SELECT version, description FROM schema_version ORDER BY version",
421 )
422 .fetch_all(&pool)
423 .await
424 .unwrap();
425 assert_eq!(rows.len(), 15);
426 assert_eq!(rows[0].0, 1);
427 assert_eq!(rows[0].1, "initial schema");
428 assert_eq!(rows[1].0, 2);
429 assert_eq!(rows[1].1, "add alerts table");
430 assert_eq!(rows[2].0, 3);
431 assert_eq!(rows[2].1, "add tls_checks table");
432 assert_eq!(rows[3].0, 4);
433 assert_eq!(rows[3].1, "add incidents table");
434 assert_eq!(rows[4].0, 5);
435 assert_eq!(rows[4].1, "add route_checks table");
436 assert_eq!(rows[5].0, 6);
437 assert_eq!(rows[5].1, "add dns_checks and whois_checks tables");
438 assert_eq!(rows[6].0, 7);
439 assert_eq!(rows[6].1, "add test_details table");
440 assert_eq!(rows[7].0, 8);
441 assert_eq!(rows[7].1, "add cors_checks table");
442 assert_eq!(rows[8].0, 9);
443 assert_eq!(rows[8].1, "add backup_checks table");
444 assert_eq!(rows[9].0, 10);
445 assert_eq!(rows[9].1, "add pending_alerts retry queue");
446 assert_eq!(rows[10].0, 11);
447 assert_eq!(rows[10].1, "add scan_pipeline_checks table");
448 assert_eq!(rows[11].0, 12);
449 assert_eq!(rows[11].1, "add systemd_checks table");
450 assert_eq!(rows[12].0, 13);
451 assert_eq!(rows[12].1, "add synckit_fleet_checks table");
452 assert_eq!(rows[13].0, 14);
453 assert_eq!(rows[13].1, "record per-trust-store results on tls_checks");
454 assert_eq!(rows[14].0, 15);
455 assert_eq!(rows[14].1, "add ca_bundle_checks table");
456
457 // Verify actual tables were created by inserting data
458 let snapshot = HealthSnapshot {
459 id: None,
460 target: "test".to_string(),
461 status: HealthStatus::Operational,
462 checked_at: "2026-03-11T00:00:00Z".to_string(),
463 response_time_ms: 50,
464 details: None,
465 error: None,
466 };
467 let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
468 assert!(id > 0);
469 }
470
471 #[tokio::test]
472 async fn migration_already_current_is_idempotent() {
473 // Running migrations on an already-migrated DB should be a no-op.
474 let pool = db::connect_in_memory().await.unwrap();
475 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15);
476
477 // Run migrations again
478 db::run_migrations(&pool).await.unwrap();
479 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15);
480
481 // schema_version should still have exactly thirteen entries (not duplicated)
482 let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version")
483 .fetch_one(&pool)
484 .await
485 .unwrap();
486 assert_eq!(count.0, 15);
487 }
488
489 #[tokio::test]
490 async fn migration_detects_pre_migration_database() {
491 // Simulate a pre-migration database: create tables manually without schema_version.
492 let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
493 let pool = sqlx::sqlite::SqlitePoolOptions::new()
494 .max_connections(1)
495 .connect_with(opts)
496 .await
497 .unwrap();
498
499 // Create the old-style tables directly (as init_schema used to do)
500 sqlx::query(
501 "CREATE TABLE health_checks (
502 id INTEGER PRIMARY KEY AUTOINCREMENT,
503 target TEXT NOT NULL,
504 status TEXT NOT NULL,
505 checked_at TEXT NOT NULL,
506 response_time_ms INTEGER NOT NULL,
507 details_json TEXT,
508 error TEXT
509 )",
510 )
511 .execute(&pool)
512 .await
513 .unwrap();
514
515 sqlx::query(
516 "CREATE TABLE test_runs (
517 id INTEGER PRIMARY KEY AUTOINCREMENT,
518 target TEXT NOT NULL,
519 started_at TEXT NOT NULL,
520 finished_at TEXT,
521 duration_secs INTEGER,
522 exit_code INTEGER,
523 passed INTEGER NOT NULL,
524 summary_json TEXT NOT NULL,
525 raw_output TEXT NOT NULL,
526 filter TEXT
527 )",
528 )
529 .execute(&pool)
530 .await
531 .unwrap();
532
533 // Insert some existing data to verify it's preserved
534 sqlx::query(
535 "INSERT INTO health_checks (target, status, checked_at, response_time_ms)
536 VALUES ('mnw', 'operational', '2026-03-10T00:00:00Z', 100)",
537 )
538 .execute(&pool)
539 .await
540 .unwrap();
541
542 // Now run migrations, should detect existing tables, stamp as v1, then run v2..v15
543 db::run_migrations(&pool).await.unwrap();
544
545 // Version should be 15 (stamped v1 + ran v2..v15)
546 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15);
547
548 // Description should indicate pre-existing
549 let row =
550 sqlx::query_as::<_, (String,)>("SELECT description FROM schema_version WHERE version = 1")
551 .fetch_one(&pool)
552 .await
553 .unwrap();
554 assert!(row.0.contains("pre-existing"));
555
556 // Existing data should be preserved
557 let history = db::get_health_history(&pool, Some("mnw"), 10)
558 .await
559 .unwrap();
560 assert_eq!(history.len(), 1);
561 assert_eq!(history[0].response_time_ms, 100);
562 }
563
564 // MCP tool tests
565
566 fn test_server(pool: sqlx::SqlitePool) -> PomServer {
567 PomServer::new(pool, test_config())
568 }
569
570 #[tokio::test]
571 async fn tool_get_status_with_data() {
572 let pool = db::connect_in_memory().await.unwrap();
573 let server = test_server(pool.clone());
574
575 // Insert health + test data
576 let snapshot = HealthSnapshot {
577 id: None,
578 target: "mnw".to_string(),
579 status: HealthStatus::Operational,
580 checked_at: "2026-03-10T00:00:00Z".to_string(),
581 response_time_ms: 95,
582 details: Some(HealthDetails {
583 version: Some("2.1.0".to_string()),
584 git_sha: None,
585 uptime: Some("3d".to_string()),
586 checks: None,
587 monitoring: None,
588 }),
589 error: None,
590 };
591 db::insert_health_check(&pool, &snapshot).await.unwrap();
592
593 let run = TestRun {
594 id: None,
595 target: "mnw".to_string(),
596 started_at: "2026-03-10T00:00:00Z".to_string(),
597 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
598 duration_secs: Some(60),
599 exit_code: Some(0),
600 passed: true,
601 summary: TestSummary {
602 steps: vec![StepResult {
603 name: "cargo test".to_string(),
604 passed: true,
605 }],
606 total_passed: Some(100),
607 total_failed: Some(0),
608 details: vec![],
609 },
610 raw_output: "all good".to_string(),
611 filter: None,
612 };
613 db::insert_test_run(&pool, &run).await.unwrap();
614
615 let result = server.get_status_impl().await.unwrap();
616 assert!(result.contains("## mnw (MakeNotWork)"));
617 assert!(result.contains("operational"));
618 assert!(result.contains("95ms"));
619 assert!(result.contains("Version: 2.1.0"));
620 assert!(result.contains("Uptime: 3d"));
621 assert!(result.contains("PASSED"));
622 assert!(result.contains("100 passed, 0 failed"));
623 assert!(result.contains("PASS cargo test"));
624 }
625
626 #[tokio::test]
627 async fn tool_get_status_no_data() {
628 let pool = db::connect_in_memory().await.unwrap();
629 let server = test_server(pool);
630
631 let result = server.get_status_impl().await.unwrap();
632 assert!(result.contains("Health: no data"));
633 assert!(result.contains("Tests: no data"));
634 }
635
636 #[tokio::test]
637 async fn tool_get_status_no_targets() {
638 let pool = db::connect_in_memory().await.unwrap();
639 let config: pom::config::Config = toml::from_str("").unwrap();
640 let server = PomServer::new(pool, config);
641
642 let result = server.get_status_impl().await.unwrap();
643 assert_eq!(result, "No targets configured.");
644 }
645
646 #[tokio::test]
647 async fn tool_list_targets() {
648 let pool = db::connect_in_memory().await.unwrap();
649 let server = test_server(pool);
650
651 let result = server.list_targets_impl().await.unwrap();
652 let targets: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
653 assert_eq!(targets.len(), 1);
654 assert_eq!(targets[0]["name"], "mnw");
655 assert_eq!(targets[0]["label"], "MakeNotWork");
656 assert_eq!(targets[0]["has_health"], true);
657 assert_eq!(targets[0]["has_tests"], false); // test_config has no tests section
658 }
659
660 #[tokio::test]
661 async fn tool_health_history_with_data() {
662 let pool = db::connect_in_memory().await.unwrap();
663 let server = test_server(pool.clone());
664
665 for i in 0..3 {
666 let snapshot = HealthSnapshot {
667 id: None,
668 target: "mnw".to_string(),
669 status: HealthStatus::Operational,
670 checked_at: format!("2026-03-10T0{i}:00:00Z"),
671 response_time_ms: 100 + i * 10,
672 details: None,
673 error: None,
674 };
675 db::insert_health_check(&pool, &snapshot).await.unwrap();
676 }
677
678 let params = pom::tools::health::HealthHistoryParams {
679 target: Some("mnw".to_string()),
680 limit: Some(2),
681 };
682 let result = server.health_history_impl(params).await.unwrap();
683 let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
684 assert_eq!(history.len(), 2);
685 }
686
687 #[tokio::test]
688 async fn tool_health_history_empty() {
689 let pool = db::connect_in_memory().await.unwrap();
690 let server = test_server(pool);
691
692 let params = pom::tools::health::HealthHistoryParams {
693 target: None,
694 limit: None,
695 };
696 let result = server.health_history_impl(params).await.unwrap();
697 assert_eq!(result, "No health check history.");
698 }
699
700 #[tokio::test]
701 async fn tool_health_history_default_limit() {
702 let pool = db::connect_in_memory().await.unwrap();
703 let server = test_server(pool.clone());
704
705 for i in 0..15 {
706 let snapshot = HealthSnapshot {
707 id: None,
708 target: "mnw".to_string(),
709 status: HealthStatus::Operational,
710 checked_at: format!("2026-03-10T{i:02}:00:00Z"),
711 response_time_ms: 100,
712 details: None,
713 error: None,
714 };
715 db::insert_health_check(&pool, &snapshot).await.unwrap();
716 }
717
718 let params = pom::tools::health::HealthHistoryParams {
719 target: None,
720 limit: None, // should default to 10
721 };
722 let result = server.health_history_impl(params).await.unwrap();
723 let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
724 assert_eq!(history.len(), 10);
725 }
726
727 #[tokio::test]
728 async fn tool_check_health_unknown_target() {
729 let pool = db::connect_in_memory().await.unwrap();
730 let server = test_server(pool);
731
732 let params = pom::tools::health::CheckHealthParams {
733 target: Some("nonexistent".to_string()),
734 };
735 let result = server.check_health_impl(params).await.unwrap();
736 assert_eq!(result, "Unknown target: nonexistent");
737 }
738
739 #[tokio::test]
740 async fn tool_test_history_strips_raw_output() {
741 let pool = db::connect_in_memory().await.unwrap();
742 let server = test_server(pool.clone());
743
744 let run = TestRun {
745 id: None,
746 target: "mnw".to_string(),
747 started_at: "2026-03-10T00:00:00Z".to_string(),
748 finished_at: None,
749 duration_secs: None,
750 exit_code: None,
751 passed: true,
752 summary: TestSummary {
753 steps: vec![],
754 total_passed: None,
755 total_failed: None,
756 details: vec![],
757 },
758 raw_output: "HUGE OUTPUT THAT SHOULD NOT APPEAR".to_string(),
759 filter: None,
760 };
761 db::insert_test_run(&pool, &run).await.unwrap();
762
763 let params = pom::tools::tests::TestHistoryParams {
764 target: Some("mnw".to_string()),
765 limit: None,
766 };
767 let result = server.test_history_impl(params).await.unwrap();
768 assert!(!result.contains("HUGE OUTPUT"));
769 assert!(result.contains("mnw"));
770 }
771
772 #[tokio::test]
773 async fn tool_test_history_empty() {
774 let pool = db::connect_in_memory().await.unwrap();
775 let server = test_server(pool);
776
777 let params = pom::tools::tests::TestHistoryParams {
778 target: None,
779 limit: None,
780 };
781 let result = server.test_history_impl(params).await.unwrap();
782 assert_eq!(result, "No test run history.");
783 }
784
785 #[tokio::test]
786 async fn tool_last_test_output_returns_raw() {
787 let pool = db::connect_in_memory().await.unwrap();
788 let server = test_server(pool.clone());
789
790 let run = TestRun {
791 id: None,
792 target: "mnw".to_string(),
793 started_at: "2026-03-10T00:00:00Z".to_string(),
794 finished_at: None,
795 duration_secs: None,
796 exit_code: None,
797 passed: true,
798 summary: TestSummary {
799 steps: vec![],
800 total_passed: None,
801 total_failed: None,
802 details: vec![],
803 },
804 raw_output: "running 42 tests\ntest result: ok".to_string(),
805 filter: None,
806 };
807 db::insert_test_run(&pool, &run).await.unwrap();
808
809 let params = pom::tools::tests::LastTestOutputParams {
810 target: "mnw".to_string(),
811 };
812 let result = server.last_test_output_impl(params).await.unwrap();
813 assert_eq!(result, "running 42 tests\ntest result: ok");
814 }
815
816 #[tokio::test]
817 async fn tool_last_test_output_no_runs() {
818 let pool = db::connect_in_memory().await.unwrap();
819 let server = test_server(pool);
820
821 let params = pom::tools::tests::LastTestOutputParams {
822 target: "mnw".to_string(),
823 };
824 let result = server.last_test_output_impl(params).await.unwrap();
825 assert_eq!(result, "No test runs found for target 'mnw'");
826 }
827
828 #[tokio::test]
829 async fn tool_run_tests_unknown_target() {
830 let pool = db::connect_in_memory().await.unwrap();
831 let server = test_server(pool);
832
833 let params = pom::tools::tests::RunTestsParams {
834 target: "nonexistent".to_string(),
835 filter: None,
836 };
837 let result = server.run_tests_impl(params).await;
838 assert!(result.is_err());
839 assert!(result.unwrap_err().to_string().contains("Unknown target"));
840 }
841
842 #[tokio::test]
843 async fn tool_run_tests_no_test_config() {
844 let pool = db::connect_in_memory().await.unwrap();
845 let server = test_server(pool); // test_config has mnw with health but no tests
846
847 let params = pom::tools::tests::RunTestsParams {
848 target: "mnw".to_string(),
849 filter: None,
850 };
851 let result = server.run_tests_impl(params).await;
852 assert!(result.is_err());
853 assert!(
854 result
855 .unwrap_err()
856 .to_string()
857 .contains("no test configuration")
858 );
859 }
860
861 // Alert tests
862
863 #[tokio::test]
864 async fn migration_v2_creates_alerts_table() {
865 let pool = db::connect_in_memory().await.unwrap();
866 let version = db::get_schema_version(&pool).await.unwrap();
867 assert_eq!(version, 15);
868
869 // Verify alerts table exists by inserting
870 let id = db::insert_alert(
871 &pool,
872 "mnw",
873 "health",
874 Some("operational"),
875 Some("error"),
876 None,
877 )
878 .await
879 .unwrap();
880 assert!(id > 0);
881 }
882
883 #[tokio::test]
884 async fn alert_insert_and_query() {
885 let pool = db::connect_in_memory().await.unwrap();
886
887 db::insert_alert(
888 &pool,
889 "health:mnw",
890 "health",
891 Some("operational"),
892 Some("error"),
893 Some("connection refused"),
894 )
895 .await
896 .unwrap();
897
898 let latest = db::get_latest_alert_for_target(&pool, "health:mnw")
899 .await
900 .unwrap();
901 assert!(latest.is_some());
902 let row = latest.unwrap();
903 assert_eq!(row.target, "health:mnw");
904 assert_eq!(row.alert_type, "health");
905 assert_eq!(row.from_status.as_deref(), Some("operational"));
906 assert_eq!(row.to_status.as_deref(), Some("error"));
907 assert_eq!(row.error.as_deref(), Some("connection refused"));
908 }
909
910 #[tokio::test]
911 async fn alert_query_returns_none_for_unknown_target() {
912 let pool = db::connect_in_memory().await.unwrap();
913
914 let latest = db::get_latest_alert_for_target(&pool, "nonexistent")
915 .await
916 .unwrap();
917 assert!(latest.is_none());
918 }
919
920 #[tokio::test]
921 async fn prune_removes_old_alerts() {
922 let pool = db::connect_in_memory().await.unwrap();
923
924 // Insert an old alert directly with old timestamp
925 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
926 sqlx::query("INSERT INTO alerts (target, alert_type, sent_at) VALUES (?, ?, ?)")
927 .bind("mnw")
928 .bind("health")
929 .bind(&old_time)
930 .execute(&pool)
931 .await
932 .unwrap();
933
934 // Insert a recent alert
935 db::insert_alert(&pool, "mnw", "health", None, None, None)
936 .await
937 .unwrap();
938
939 let result = db::prune_old_records(&pool, 30).await.unwrap();
940 assert_eq!(result.alerts, 1);
941
942 // Recent alert should remain
943 let latest = db::get_latest_alert_for_target(&pool, "mnw").await.unwrap();
944 assert!(latest.is_some());
945 }
946
947 // TLS check tests
948
949 #[tokio::test]
950 async fn migration_v3_creates_tls_checks_table() {
951 let pool = db::connect_in_memory().await.unwrap();
952 let version = db::get_schema_version(&pool).await.unwrap();
953 assert_eq!(version, 15);
954
955 // Verify tls_checks table exists by inserting
956 let status = pom::types::TlsStatus {
957 target: "mnw".to_string(),
958 host: "makenot.work".to_string(),
959 port: 443,
960 valid: true,
961 days_remaining: 47,
962 not_before: "2026-01-10T00:00:00Z".to_string(),
963 not_after: "2026-04-27T00:00:00Z".to_string(),
964 subject: "CN=makenot.work".to_string(),
965 issuer: "CN=Let's Encrypt".to_string(),
966 checked_at: "2026-03-11T00:00:00Z".to_string(),
967 error: None,
968 webpki_trusted: true,
969 platform_trusted: true,
970 webpki_error: None,
971 platform_error: None,
972 };
973 let id = db::insert_tls_check(&pool, &status).await.unwrap();
974 assert!(id > 0);
975 }
976
977 #[tokio::test]
978 async fn tls_check_insert_and_query() {
979 let pool = db::connect_in_memory().await.unwrap();
980
981 let status = pom::types::TlsStatus {
982 target: "mnw".to_string(),
983 host: "makenot.work".to_string(),
984 port: 443,
985 valid: true,
986 days_remaining: 47,
987 not_before: "2026-01-10T00:00:00Z".to_string(),
988 not_after: "2026-04-27T00:00:00Z".to_string(),
989 subject: "CN=makenot.work".to_string(),
990 issuer: "CN=Let's Encrypt".to_string(),
991 checked_at: "2026-03-11T00:00:00Z".to_string(),
992 error: None,
993 webpki_trusted: true,
994 platform_trusted: true,
995 webpki_error: None,
996 platform_error: None,
997 };
998 db::insert_tls_check(&pool, &status).await.unwrap();
999
1000 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
1001 assert!(latest.is_some());
1002 let row = latest.unwrap();
1003 assert_eq!(row.host, "makenot.work");
1004 assert!(row.valid);
1005 assert_eq!(row.days_remaining, 47);
1006 assert_eq!(row.subject, "CN=makenot.work");
1007 assert!(row.error.is_none());
1008 }
1009
1010 #[tokio::test]
1011 async fn tls_check_error_stored() {
1012 let pool = db::connect_in_memory().await.unwrap();
1013
1014 let status = pom::types::TlsStatus {
1015 target: "mnw".to_string(),
1016 host: "makenot.work".to_string(),
1017 port: 443,
1018 valid: false,
1019 days_remaining: 0,
1020 not_before: String::new(),
1021 not_after: String::new(),
1022 subject: String::new(),
1023 issuer: String::new(),
1024 checked_at: "2026-03-11T00:00:00Z".to_string(),
1025 error: Some("connection refused".to_string()),
1026 webpki_trusted: false,
1027 platform_trusted: false,
1028 webpki_error: None,
1029 platform_error: None,
1030 };
1031 db::insert_tls_check(&pool, &status).await.unwrap();
1032
1033 let latest = db::get_latest_tls_check(&pool, "mnw")
1034 .await
1035 .unwrap()
1036 .unwrap();
1037 assert!(!latest.valid);
1038 assert_eq!(latest.error.as_deref(), Some("connection refused"));
1039 }
1040
1041 #[tokio::test]
1042 async fn prune_removes_old_tls_checks() {
1043 let pool = db::connect_in_memory().await.unwrap();
1044
1045 // Insert old TLS check
1046 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1047 sqlx::query(
1048 "INSERT INTO tls_checks (target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at)
1049 VALUES (?, ?, ?, ?, '', '', '', '', ?)",
1050 )
1051 .bind("mnw")
1052 .bind("makenot.work")
1053 .bind(true)
1054 .bind(47)
1055 .bind(&old_time)
1056 .execute(&pool)
1057 .await
1058 .unwrap();
1059
1060 // Insert recent TLS check
1061 let status = pom::types::TlsStatus {
1062 target: "mnw".to_string(),
1063 host: "makenot.work".to_string(),
1064 port: 443,
1065 valid: true,
1066 days_remaining: 47,
1067 not_before: String::new(),
1068 not_after: String::new(),
1069 subject: String::new(),
1070 issuer: String::new(),
1071 checked_at: chrono::Utc::now().to_rfc3339(),
1072 error: None,
1073 webpki_trusted: true,
1074 platform_trusted: true,
1075 webpki_error: None,
1076 platform_error: None,
1077 };
1078 db::insert_tls_check(&pool, &status).await.unwrap();
1079
1080 let result = db::prune_old_records(&pool, 30).await.unwrap();
1081 assert_eq!(result.tls, 1);
1082
1083 // Recent check should remain
1084 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
1085 assert!(latest.is_some());
1086 }
1087
1088 #[tokio::test]
1089 async fn api_status_target_includes_tls() {
1090 let pool = db::connect_in_memory().await.unwrap();
1091
1092 // Config with TLS
1093 let config: pom::config::Config = toml::from_str(
1094 r#"
1095 [targets.mnw]
1096 label = "MakeNotWork"
1097 [targets.mnw.health]
1098 url = "https://makenot.work/health"
1099 [targets.mnw.tls]
1100 host = "makenot.work"
1101 "#,
1102 )
1103 .unwrap();
1104 let app = pom::api::router(pool.clone(), config, None);
1105
1106 // Insert TLS check data
1107 let status = pom::types::TlsStatus {
1108 target: "mnw".to_string(),
1109 host: "makenot.work".to_string(),
1110 port: 443,
1111 valid: true,
1112 days_remaining: 47,
1113 not_before: "2026-01-10T00:00:00Z".to_string(),
1114 not_after: "2026-04-27T00:00:00Z".to_string(),
1115 subject: "CN=makenot.work".to_string(),
1116 issuer: "CN=Let's Encrypt".to_string(),
1117 checked_at: "2026-03-11T00:00:00Z".to_string(),
1118 error: None,
1119 webpki_trusted: true,
1120 platform_trusted: true,
1121 webpki_error: None,
1122 platform_error: None,
1123 };
1124 db::insert_tls_check(&pool, &status).await.unwrap();
1125
1126 let (http_status, json) = api_get(&app, "/api/status/mnw").await;
1127 assert_eq!(http_status, 200);
1128 assert!(json["tls"].is_object());
1129 assert_eq!(json["tls"]["host"], "makenot.work");
1130 assert_eq!(json["tls"]["days_remaining"], 47);
1131 assert_eq!(json["tls"]["valid"], true);
1132 }
1133
1134 #[tokio::test]
1135 async fn api_status_target_no_tls_omits_field() {
1136 let pool = db::connect_in_memory().await.unwrap();
1137 let config = test_config(); // no TLS config
1138 let app = pom::api::router(pool, config, None);
1139
1140 let (http_status, json) = api_get(&app, "/api/status/mnw").await;
1141 assert_eq!(http_status, 200);
1142 // tls field should be absent (skip_serializing_if)
1143 assert!(json.get("tls").is_none());
1144 }
1145
1146 #[tokio::test]
1147 async fn config_with_tls_parses() {
1148 let toml_str = r#"
1149 [targets.mnw]
1150 label = "MakeNotWork"
1151 [targets.mnw.tls]
1152 host = "makenot.work"
1153 port = 8443
1154 warn_days = 30
1155 "#;
1156 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
1157 let mnw = config.get_target("mnw").unwrap();
1158 let tls = mnw.tls.as_ref().unwrap();
1159 assert_eq!(tls.host, "makenot.work");
1160 assert_eq!(tls.port, 8443);
1161 assert_eq!(tls.warn_days, 30);
1162 }
1163
1164 #[tokio::test]
1165 async fn config_with_alerts_parses() {
1166 let toml = r#"
1167 [targets.mnw]
1168 label = "MakeNotWork"
1169 [targets.mnw.health]
1170 url = "https://makenot.work/health"
1171
1172 [alerts]
1173 postmark_token = "test-token-123"
1174 to = "pom-alerts@makenot.work"
1175 cooldown_secs = 120
1176 "#;
1177 let config: pom::config::Config = toml::from_str(toml).unwrap();
1178 let alerts = config.alerts.unwrap();
1179 assert_eq!(alerts.postmark_token.as_deref(), Some("test-token-123"));
1180 assert_eq!(alerts.to, "pom-alerts@makenot.work");
1181 assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
1182 assert_eq!(alerts.cooldown_secs, 120);
1183 }
1184
1185 // Incident tests
1186
1187 #[tokio::test]
1188 async fn migration_v4_creates_incidents_table() {
1189 let pool = db::connect_in_memory().await.unwrap();
1190 let version = db::get_schema_version(&pool).await.unwrap();
1191 assert_eq!(version, 15);
1192
1193 // Verify incidents table exists by inserting
1194 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
1195 .await
1196 .unwrap();
1197 assert!(id > 0);
1198 }
1199
1200 #[tokio::test]
1201 async fn incident_insert_and_close_lifecycle() {
1202 let pool = db::connect_in_memory().await.unwrap();
1203
1204 // Open an incident
1205 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
1206 .await
1207 .unwrap();
1208 assert!(id > 0);
1209
1210 // Should be visible as open
1211 let open = db::get_open_incident(&pool, "mnw").await.unwrap();
1212 assert!(open.is_some());
1213 let open = open.unwrap();
1214 assert_eq!(open.from_status, "operational");
1215 assert_eq!(open.to_status, "degraded");
1216 assert!(open.ended_at.is_none());
1217
1218 // Close it
1219 let closed_count = db::close_open_incidents(&pool, "mnw").await.unwrap();
1220 assert_eq!(closed_count, 1);
1221
1222 // No more open incidents
1223 let open = db::get_open_incident(&pool, "mnw").await.unwrap();
1224 assert!(open.is_none());
1225
1226 // Recent incidents should include the closed one
1227 let recent = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
1228 assert_eq!(recent.len(), 1);
1229 assert!(recent[0].ended_at.is_some());
1230 assert!(recent[0].duration_secs.is_some());
1231 }
1232
1233 /// The restart hole, in miniature.
1234 ///
1235 /// An incident closes on a *confirmed* transition back to Operational. If the
1236 /// process restarts across that recovery, the transition gate seeds itself from
1237 /// the last stored health, observes the same status, reports no transition, and
1238 /// nothing ever closes the record: the instance reads `failed` forever while the
1239 /// target is up.
1240 ///
1241 /// Measured on astra 2026-08-23. Incident 307 (`mt`) opened 2026-08-16 16:57 and
1242 /// still had a NULL `ended_at` a week later, while the two incidents opened by
1243 /// the same blip in the same minute (`mnw`, `htpy`) both closed an hour on and
1244 /// mt's health sat at 100% uptime throughout.
1245 ///
1246 /// The reconciliation is `close_open_incidents` at seed time when the seeded
1247 /// status is Operational. This pins the property it relies on: closing is safe
1248 /// to run when there is nothing to close, so the boot path can be unconditional
1249 /// rather than having to ask first.
1250 #[tokio::test]
1251 async fn closing_incidents_is_idempotent_so_a_boot_time_reconcile_is_safe() {
1252 let pool = db::connect_in_memory().await.unwrap();
1253
1254 // Nothing open: the reconcile a healthy target performs on every start.
1255 assert_eq!(db::close_open_incidents(&pool, "mt").await.unwrap(), 0);
1256
1257 db::insert_incident(&pool, "mt", "operational", "unreachable")
1258 .await
1259 .unwrap();
1260 // The recovery the restart swallowed, applied late.
1261 assert_eq!(db::close_open_incidents(&pool, "mt").await.unwrap(), 1);
1262 assert!(db::get_open_incident(&pool, "mt").await.unwrap().is_none());
1263 // And again on the next start, with nothing left to do.
1264 assert_eq!(db::close_open_incidents(&pool, "mt").await.unwrap(), 0);
1265 }
1266
1267 #[tokio::test]
1268 async fn incident_close_only_affects_target() {
1269 let pool = db::connect_in_memory().await.unwrap();
1270
1271 db::insert_incident(&pool, "mnw", "operational", "error")
1272 .await
1273 .unwrap();
1274 db::insert_incident(&pool, "other", "operational", "error")
1275 .await
1276 .unwrap();
1277
1278 // Close only mnw
1279 db::close_open_incidents(&pool, "mnw").await.unwrap();
1280
1281 assert!(db::get_open_incident(&pool, "mnw").await.unwrap().is_none());
1282 assert!(
1283 db::get_open_incident(&pool, "other")
1284 .await
1285 .unwrap()
1286 .is_some()
1287 );
1288 }
1289
1290 #[tokio::test]
1291 async fn prune_removes_closed_incidents_only() {
1292 let pool = db::connect_in_memory().await.unwrap();
1293
1294 // Insert an old closed incident
1295 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1296 sqlx::query(
1297 "INSERT INTO incidents (target, started_at, ended_at, duration_secs, from_status, to_status)
1298 VALUES (?, ?, ?, 3600, 'operational', 'error')",
1299 )
1300 .bind("mnw")
1301 .bind(&old_time)
1302 .bind(&old_time)
1303 .execute(&pool)
1304 .await
1305 .unwrap();
1306
1307 // Insert an old open incident (should NOT be pruned)
1308 sqlx::query(
1309 "INSERT INTO incidents (target, started_at, from_status, to_status)
1310 VALUES (?, ?, 'operational', 'error')",
1311 )
1312 .bind("mnw")
1313 .bind(&old_time)
1314 .execute(&pool)
1315 .await
1316 .unwrap();
1317
1318 let result = db::prune_old_records(&pool, 30).await.unwrap();
1319 assert_eq!(result.incidents, 1); // only the closed one
1320
1321 // The open incident should remain
1322 let remaining = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
1323 assert_eq!(remaining.len(), 1);
1324 assert!(remaining[0].ended_at.is_none());
1325 }
1326
1327 #[tokio::test]
1328 async fn api_status_includes_incidents() {
1329 let pool = db::connect_in_memory().await.unwrap();
1330 let config = test_config();
1331 let app = pom::api::router(pool.clone(), config, None);
1332
1333 // Insert an open incident
1334 db::insert_incident(&pool, "mnw", "operational", "degraded")
1335 .await
1336 .unwrap();
1337
1338 let (status, json) = api_get(&app, "/api/status/mnw").await;
1339 assert_eq!(status, 200);
1340 assert!(json["current_incident"].is_object());
1341 assert_eq!(json["current_incident"]["from_status"], "operational");
1342 assert_eq!(json["current_incident"]["to_status"], "degraded");
1343 assert!(!json["incidents"].as_array().unwrap().is_empty());
1344 }
1345
1346 #[tokio::test]
1347 async fn api_status_no_incidents_omits_fields() {
1348 let pool = db::connect_in_memory().await.unwrap();
1349 let config = test_config();
1350 let app = pom::api::router(pool, config, None);
1351
1352 let (status, json) = api_get(&app, "/api/status/mnw").await;
1353 assert_eq!(status, 200);
1354 // current_incident and incidents should be absent
1355 assert!(json.get("current_incident").is_none());
1356 assert!(json.get("incidents").is_none());
1357 }
1358
1359 // Route check tests
1360
1361 #[tokio::test]
1362 async fn migration_v5_creates_route_checks_table() {
1363 let pool = db::connect_in_memory().await.unwrap();
1364 let version = db::get_schema_version(&pool).await.unwrap();
1365 assert_eq!(version, 15);
1366
1367 // Verify route_checks table exists by inserting
1368 let result = pom::checks::routes::RouteCheckResult {
1369 target: "mnw".to_string(),
1370 path: "/".to_string(),
1371 status_code: 200,
1372 ok: true,
1373 checked_at: chrono::Utc::now().to_rfc3339(),
1374 response_time_ms: 50,
1375 error: None,
1376 };
1377 let id = db::insert_route_check(&pool, &result).await.unwrap();
1378 assert!(id > 0);
1379 }
1380
1381 #[tokio::test]
1382 async fn route_check_insert_and_latest_query() {
1383 let pool = db::connect_in_memory().await.unwrap();
1384
1385 // Insert two checks for different paths
1386 let r1 = pom::checks::routes::RouteCheckResult {
1387 target: "mnw".to_string(),
1388 path: "/".to_string(),
1389 status_code: 200,
1390 ok: true,
1391 checked_at: "2026-03-13T00:00:00Z".to_string(),
1392 response_time_ms: 50,
1393 error: None,
1394 };
1395 let r2 = pom::checks::routes::RouteCheckResult {
1396 target: "mnw".to_string(),
1397 path: "/docs".to_string(),
1398 status_code: 404,
1399 ok: false,
1400 checked_at: "2026-03-13T00:00:00Z".to_string(),
1401 response_time_ms: 30,
1402 error: Some("HTTP 404".to_string()),
1403 };
1404 db::insert_route_check(&pool, &r1).await.unwrap();
1405 db::insert_route_check(&pool, &r2).await.unwrap();
1406
1407 // Insert a newer check for "/" (should supersede the first)
1408 let r3 = pom::checks::routes::RouteCheckResult {
1409 target: "mnw".to_string(),
1410 path: "/".to_string(),
1411 status_code: 200,
1412 ok: true,
1413 checked_at: "2026-03-13T01:00:00Z".to_string(),
1414 response_time_ms: 45,
1415 error: None,
1416 };
1417 db::insert_route_check(&pool, &r3).await.unwrap();
1418
1419 let latest = db::get_latest_route_checks(&pool, "mnw").await.unwrap();
1420 assert_eq!(latest.len(), 2);
1421
1422 // "/" should have the newer check (45ms)
1423 let root = latest.iter().find(|r| r.path == "/").unwrap();
1424 assert_eq!(root.response_time_ms, 45);
1425 assert!(root.ok);
1426
1427 // "/docs" should still show 404
1428 let docs = latest.iter().find(|r| r.path == "/docs").unwrap();
1429 assert!(!docs.ok);
1430 assert_eq!(docs.status_code, 404);
1431 }
1432
1433 #[tokio::test]
1434 async fn route_check_prune() {
1435 let pool = db::connect_in_memory().await.unwrap();
1436
1437 let old = pom::checks::routes::RouteCheckResult {
1438 target: "mnw".to_string(),
1439 path: "/".to_string(),
1440 status_code: 200,
1441 ok: true,
1442 checked_at: "2020-01-01T00:00:00Z".to_string(),
1443 response_time_ms: 50,
1444 error: None,
1445 };
1446 db::insert_route_check(&pool, &old).await.unwrap();
1447
1448 let result = db::prune_old_records(&pool, 30).await.unwrap();
1449 assert_eq!(result.routes, 1);
1450 }
1451
1452 #[tokio::test]
1453 async fn api_status_includes_route_status() {
1454 let pool = db::connect_in_memory().await.unwrap();
1455 let config: pom::config::Config = toml::from_str(
1456 r#"
1457 [targets.mnw]
1458 label = "MakeNotWork"
1459 expected_routes = ["/"]
1460 [targets.mnw.health]
1461 url = "https://makenot.work/health"
1462 "#,
1463 )
1464 .unwrap();
1465 let app = pom::api::router(pool.clone(), config, None);
1466
1467 // Insert route checks
1468 let r1 = pom::checks::routes::RouteCheckResult {
1469 target: "mnw".to_string(),
1470 path: "/".to_string(),
1471 status_code: 200,
1472 ok: true,
1473 checked_at: chrono::Utc::now().to_rfc3339(),
1474 response_time_ms: 50,
1475 error: None,
1476 };
1477 db::insert_route_check(&pool, &r1).await.unwrap();
1478
1479 let (status, json) = api_get(&app, "/api/status/mnw").await;
1480 assert_eq!(status, 200);
1481 let routes = json["route_status"].as_array().unwrap();
1482 assert_eq!(routes.len(), 1);
1483 assert_eq!(routes[0]["path"], "/");
1484 assert_eq!(routes[0]["ok"], true);
1485 }
1486
1487 #[tokio::test]
1488 async fn api_status_omits_empty_route_status() {
1489 let pool = db::connect_in_memory().await.unwrap();
1490 let config = test_config();
1491 let app = pom::api::router(pool.clone(), config, None);
1492
1493 let (status, json) = api_get(&app, "/api/status/mnw").await;
1494 assert_eq!(status, 200);
1495 // route_status should be omitted when empty (skip_serializing_if)
1496 assert!(json.get("route_status").is_none());
1497 }
1498
1499 // Latency trending tests
1500
1501 #[tokio::test]
1502 async fn get_response_times_returns_ordered_data() {
1503 let pool = db::connect_in_memory().await.unwrap();
1504
1505 for i in 0..5 {
1506 let snapshot = HealthSnapshot {
1507 id: None,
1508 target: "mnw".to_string(),
1509 status: HealthStatus::Operational,
1510 checked_at: format!("2026-03-10T0{i}:00:00+00:00"),
1511 response_time_ms: 100 + i * 10,
1512 details: None,
1513 error: None,
1514 };
1515 db::insert_health_check(&pool, &snapshot).await.unwrap();
1516 }
1517
1518 let times = db::get_response_times(&pool, "mnw", "2026-03-10T00:00:00+00:00")
1519 .await
1520 .unwrap();
1521 assert_eq!(times.len(), 5);
1522 // Verify ASC ordering
1523 assert!(times[0].1 <= times[4].1);
1524 }
1525
1526 #[tokio::test]
1527 async fn get_recent_response_times_filters_operational_only() {
1528 let pool = db::connect_in_memory().await.unwrap();
1529
1530 // Insert operational checks
1531 for i in 0..3 {
1532 let snapshot = HealthSnapshot {
1533 id: None,
1534 target: "mnw".to_string(),
1535 status: HealthStatus::Operational,
1536 checked_at: format!("2026-03-10T0{i}:00:00Z"),
1537 response_time_ms: 100 + i * 10,
1538 details: None,
1539 error: None,
1540 };
1541 db::insert_health_check(&pool, &snapshot).await.unwrap();
1542 }
1543
1544 // Insert non-operational checks
1545 let error_snapshot = HealthSnapshot {
1546 id: None,
1547 target: "mnw".to_string(),
1548 status: HealthStatus::Error,
1549 checked_at: "2026-03-10T03:00:00Z".to_string(),
1550 response_time_ms: 5000,
1551 details: None,
1552 error: Some("timeout".to_string()),
1553 };
1554 db::insert_health_check(&pool, &error_snapshot)
1555 .await
1556 .unwrap();
1557
1558 let times = db::get_recent_response_times(&pool, "mnw", 10)
1559 .await
1560 .unwrap();
1561 assert_eq!(times.len(), 3); // only operational
1562 // All should be our operational values, not the 5000ms error
1563 assert!(times.iter().all(|&t| t < 5000));
1564 }
1565
1566 #[tokio::test]
1567 async fn api_trends_returns_buckets() {
1568 let pool = db::connect_in_memory().await.unwrap();
1569 let config = test_config();
1570 let app = pom::api::router(pool.clone(), config, None);
1571
1572 // Insert hourly data points
1573 for i in 0..5 {
1574 let snapshot = HealthSnapshot {
1575 id: None,
1576 target: "mnw".to_string(),
1577 status: HealthStatus::Operational,
1578 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
1579 response_time_ms: 100 + i * 20,
1580 details: None,
1581 error: None,
1582 };
1583 db::insert_health_check(&pool, &snapshot).await.unwrap();
1584 }
1585
1586 let (status, json) = api_get(&app, "/api/trends/mnw?hours=24&bucket_minutes=60").await;
1587 assert_eq!(status, 200);
1588 assert_eq!(json["target"], "mnw");
1589 assert_eq!(json["window_hours"], 24);
1590 assert_eq!(json["bucket_minutes"], 60);
1591 assert!(!json["buckets"].as_array().unwrap().is_empty());
1592 assert!(json["overall"].is_object());
1593 }
1594
1595 #[tokio::test]
1596 async fn api_trends_nonexistent_target() {
1597 let pool = db::connect_in_memory().await.unwrap();
1598 let config = test_config();
1599 let app = pom::api::router(pool, config, None);
1600
1601 let (status, json) = api_get(&app, "/api/trends/nonexistent").await;
1602 assert_eq!(status, 404);
1603 assert!(json["error"].as_str().unwrap().contains("unknown target"));
1604 }
1605
1606 #[tokio::test]
1607 async fn api_status_includes_latency_24h_with_data() {
1608 let pool = db::connect_in_memory().await.unwrap();
1609 let config = test_config();
1610 let app = pom::api::router(pool.clone(), config, None);
1611
1612 // Insert recent operational check
1613 let snapshot = HealthSnapshot {
1614 id: None,
1615 target: "mnw".to_string(),
1616 status: HealthStatus::Operational,
1617 checked_at: chrono::Utc::now().to_rfc3339(),
1618 response_time_ms: 120,
1619 details: None,
1620 error: None,
1621 };
1622 db::insert_health_check(&pool, &snapshot).await.unwrap();
1623
1624 let (status, json) = api_get(&app, "/api/status/mnw").await;
1625 assert_eq!(status, 200);
1626 assert!(json["latency_24h"].is_object());
1627 assert_eq!(json["latency_24h"]["min_ms"], 120);
1628 assert_eq!(json["latency_24h"]["sample_count"], 1);
1629 }
1630
1631 #[tokio::test]
1632 async fn api_status_omits_latency_24h_when_no_data() {
1633 let pool = db::connect_in_memory().await.unwrap();
1634 let config = test_config();
1635 let app = pom::api::router(pool, config, None);
1636
1637 let (status, json) = api_get(&app, "/api/status/mnw").await;
1638 assert_eq!(status, 200);
1639 // latency_24h should be absent (skip_serializing_if)
1640 assert!(json.get("latency_24h").is_none());
1641 }
1642
1643 #[tokio::test]
1644 async fn config_trending_parses() {
1645 let toml = r#"
1646 [targets.mnw]
1647 label = "MakeNotWork"
1648 [targets.mnw.health]
1649 url = "https://makenot.work/health"
1650 [targets.mnw.health.trending]
1651 baseline_window_hours = 48
1652 spike_threshold = 1.5
1653 "#;
1654 let config: pom::config::Config = toml::from_str(toml).unwrap();
1655 let trending = config
1656 .get_target("mnw")
1657 .unwrap()
1658 .health
1659 .as_ref()
1660 .unwrap()
1661 .trending
1662 .as_ref()
1663 .unwrap();
1664 assert_eq!(trending.baseline_window_hours, 48);
1665 assert!((trending.spike_threshold - 1.5).abs() < f64::EPSILON);
1666 }
1667
1668 #[tokio::test]
1669 async fn config_with_health_expect_parses() {
1670 let toml = r#"
1671 [targets.mnw]
1672 label = "MakeNotWork"
1673 [targets.mnw.health]
1674 url = "https://makenot.work/health"
1675 [targets.mnw.health.expect]
1676 status_code = 200
1677 json_fields = { "status" = "operational" }
1678 "#;
1679 let config: pom::config::Config = toml::from_str(toml).unwrap();
1680 let expect = config
1681 .get_target("mnw")
1682 .unwrap()
1683 .health
1684 .as_ref()
1685 .unwrap()
1686 .expect
1687 .as_ref()
1688 .unwrap();
1689 assert_eq!(expect.status_code, Some(200));
1690 assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
1691 }
1692
1693 // Test staleness tests
1694
1695 fn test_config_with_tests() -> pom::config::Config {
1696 toml::from_str(
1697 r#"
1698 [targets.mnw]
1699 label = "MakeNotWork"
1700 [targets.mnw.health]
1701 url = "https://makenot.work/health"
1702 [targets.mnw.tests]
1703 ssh = "max@host"
1704 command = "./ci.sh"
1705 staleness_days = 7
1706 "#,
1707 )
1708 .unwrap()
1709 }
1710
1711 #[tokio::test]
1712 async fn get_version_at_time_returns_version() {
1713 let pool = db::connect_in_memory().await.unwrap();
1714
1715 // Insert a health check with version details
1716 let snapshot = HealthSnapshot {
1717 id: None,
1718 target: "mnw".to_string(),
1719 status: HealthStatus::Operational,
1720 checked_at: "2026-03-10T00:00:00Z".to_string(),
1721 response_time_ms: 95,
1722 details: Some(HealthDetails {
1723 version: Some("0.1.8".to_string()),
1724 git_sha: None,
1725 uptime: None,
1726 checks: None,
1727 monitoring: None,
1728 }),
1729 error: None,
1730 };
1731 db::insert_health_check(&pool, &snapshot).await.unwrap();
1732
1733 let version = db::get_version_at_time(&pool, "mnw", "2026-03-10T01:00:00Z")
1734 .await
1735 .unwrap();
1736 assert_eq!(version, Some("0.1.8".to_string()));
1737 }
1738
1739 #[tokio::test]
1740 async fn get_version_at_time_returns_none_when_no_data() {
1741 let pool = db::connect_in_memory().await.unwrap();
1742
1743 let version = db::get_version_at_time(&pool, "mnw", "2026-03-10T01:00:00Z")
1744 .await
1745 .unwrap();
1746 assert!(version.is_none());
1747 }
1748
1749 #[tokio::test]
1750 async fn api_status_includes_staleness_version_change() {
1751 let pool = db::connect_in_memory().await.unwrap();
1752 let config = test_config_with_tests();
1753 let app = pom::api::router(pool.clone(), config, None);
1754
1755 // Insert health check with version 0.1.8 before test run
1756 let old_health = HealthSnapshot {
1757 id: None,
1758 target: "mnw".to_string(),
1759 status: HealthStatus::Operational,
1760 checked_at: "2026-03-09T00:00:00Z".to_string(),
1761 response_time_ms: 95,
1762 details: Some(HealthDetails {
1763 version: Some("0.1.8".to_string()),
1764 git_sha: None,
1765 uptime: None,
1766 checks: None,
1767 monitoring: None,
1768 }),
1769 error: None,
1770 };
1771 db::insert_health_check(&pool, &old_health).await.unwrap();
1772
1773 // Insert test run at a time when version was 0.1.8
1774 let run = TestRun {
1775 id: None,
1776 target: "mnw".to_string(),
1777 started_at: chrono::Utc::now().to_rfc3339(),
1778 finished_at: None,
1779 duration_secs: Some(60),
1780 exit_code: Some(0),
1781 passed: true,
1782 summary: TestSummary {
1783 steps: vec![],
1784 total_passed: Some(100),
1785 total_failed: Some(0),
1786 details: vec![],
1787 },
1788 raw_output: String::new(),
1789 filter: None,
1790 };
1791 db::insert_test_run(&pool, &run).await.unwrap();
1792
1793 // Insert current health check with version 0.1.9
1794 let new_health = HealthSnapshot {
1795 id: None,
1796 target: "mnw".to_string(),
1797 status: HealthStatus::Operational,
1798 checked_at: chrono::Utc::now().to_rfc3339(),
1799 response_time_ms: 95,
1800 details: Some(HealthDetails {
1801 version: Some("0.1.9".to_string()),
1802 git_sha: None,
1803 uptime: None,
1804 checks: None,
1805 monitoring: None,
1806 }),
1807 error: None,
1808 };
1809 db::insert_health_check(&pool, &new_health).await.unwrap();
1810
1811 let (status, json) = api_get(&app, "/api/status/mnw").await;
1812 assert_eq!(status, 200);
1813 assert!(json["test_staleness"].is_object());
1814 assert_eq!(json["test_staleness"]["stale"], true);
1815 let reason = json["test_staleness"]["reason"].as_str().unwrap();
1816 assert!(reason.contains("version changed"), "reason was: {reason}");
1817 }
1818
1819 #[tokio::test]
1820 async fn api_status_includes_staleness_by_age() {
1821 let pool = db::connect_in_memory().await.unwrap();
1822 let config = test_config_with_tests();
1823 let app = pom::api::router(pool.clone(), config, None);
1824
1825 // Insert old health check
1826 let old_time = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
1827 let health = HealthSnapshot {
1828 id: None,
1829 target: "mnw".to_string(),
1830 status: HealthStatus::Operational,
1831 checked_at: old_time.clone(),
1832 response_time_ms: 95,
1833 details: Some(HealthDetails {
1834 version: Some("0.1.9".to_string()),
1835 git_sha: None,
1836 uptime: None,
1837 checks: None,
1838 monitoring: None,
1839 }),
1840 error: None,
1841 };
1842 db::insert_health_check(&pool, &health).await.unwrap();
1843
1844 // Insert old test run (10 days ago, same version)
1845 let run = TestRun {
1846 id: None,
1847 target: "mnw".to_string(),
1848 started_at: old_time,
1849 finished_at: None,
1850 duration_secs: Some(60),
1851 exit_code: Some(0),
1852 passed: true,
1853 summary: TestSummary {
1854 steps: vec![],
1855 total_passed: Some(100),
1856 total_failed: Some(0),
1857 details: vec![],
1858 },
1859 raw_output: String::new(),
1860 filter: None,
1861 };
1862 db::insert_test_run(&pool, &run).await.unwrap();
1863
1864 // Insert current health (same version)
1865 let current_health = HealthSnapshot {
1866 id: None,
1867 target: "mnw".to_string(),
1868 status: HealthStatus::Operational,
1869 checked_at: chrono::Utc::now().to_rfc3339(),
1870 response_time_ms: 95,
1871 details: Some(HealthDetails {
1872 version: Some("0.1.9".to_string()),
1873 git_sha: None,
1874 uptime: None,
1875 checks: None,
1876 monitoring: None,
1877 }),
1878 error: None,
1879 };
1880 db::insert_health_check(&pool, &current_health)
1881 .await
1882 .unwrap();
1883
1884 let (status, json) = api_get(&app, "/api/status/mnw").await;
1885 assert_eq!(status, 200);
1886 assert!(json["test_staleness"].is_object());
1887 assert_eq!(json["test_staleness"]["stale"], true);
1888 let reason = json["test_staleness"]["reason"].as_str().unwrap();
1889 assert!(reason.contains("days old"), "reason was: {reason}");
1890 }
1891
1892 #[tokio::test]
1893 async fn api_status_not_stale_when_fresh() {
1894 let pool = db::connect_in_memory().await.unwrap();
1895 let config = test_config_with_tests();
1896 let app = pom::api::router(pool.clone(), config, None);
1897
1898 // Insert health check with version
1899 let health = HealthSnapshot {
1900 id: None,
1901 target: "mnw".to_string(),
1902 status: HealthStatus::Operational,
1903 checked_at: chrono::Utc::now().to_rfc3339(),
1904 response_time_ms: 95,
1905 details: Some(HealthDetails {
1906 version: Some("0.1.9".to_string()),
1907 git_sha: None,
1908 uptime: None,
1909 checks: None,
1910 monitoring: None,
1911 }),
1912 error: None,
1913 };
1914 db::insert_health_check(&pool, &health).await.unwrap();
1915
1916 // Insert recent test run
1917 let run = TestRun {
1918 id: None,
1919 target: "mnw".to_string(),
1920 started_at: chrono::Utc::now().to_rfc3339(),
1921 finished_at: None,
1922 duration_secs: Some(60),
1923 exit_code: Some(0),
1924 passed: true,
1925 summary: TestSummary {
1926 steps: vec![],
1927 total_passed: Some(100),
1928 total_failed: Some(0),
1929 details: vec![],
1930 },
1931 raw_output: String::new(),
1932 filter: None,
1933 };
1934 db::insert_test_run(&pool, &run).await.unwrap();
1935
1936 let (status, json) = api_get(&app, "/api/status/mnw").await;
1937 assert_eq!(status, 200);
1938 assert!(json["test_staleness"].is_object());
1939 assert_eq!(json["test_staleness"]["stale"], false);
1940 }
1941
1942 #[tokio::test]
1943 async fn config_staleness_days_parses() {
1944 let toml = r#"
1945 [targets.mnw]
1946 label = "MakeNotWork"
1947 [targets.mnw.tests]
1948 ssh = "host"
1949 command = "./ci.sh"
1950 staleness_days = 14
1951 "#;
1952 let config: pom::config::Config = toml::from_str(toml).unwrap();
1953 assert_eq!(
1954 config
1955 .get_target("mnw")
1956 .unwrap()
1957 .tests
1958 .as_ref()
1959 .unwrap()
1960 .staleness_days,
1961 14
1962 );
1963 }
1964
1965 #[tokio::test]
1966 async fn tool_get_status_shows_staleness() {
1967 let pool = db::connect_in_memory().await.unwrap();
1968 let config = test_config_with_tests();
1969 let server = PomServer::new(pool.clone(), config);
1970
1971 // No test data, should show stale
1972 let result = server.get_status_impl().await.unwrap();
1973 assert!(result.contains("STALE"), "output was: {result}");
1974 assert!(
1975 result.contains("no tests have been run"),
1976 "output was: {result}"
1977 );
1978 }
1979
1980 #[tokio::test]
1981 async fn api_status_no_staleness_without_tests_config() {
1982 let pool = db::connect_in_memory().await.unwrap();
1983 let config = test_config(); // no tests section
1984 let app = pom::api::router(pool, config, None);
1985
1986 let (status, json) = api_get(&app, "/api/status/mnw").await;
1987 assert_eq!(status, 200);
1988 // test_staleness should be absent when no tests config
1989 assert!(json.get("test_staleness").is_none());
1990 }
1991
1992 // Prune days=0 guard tests
1993
1994 #[tokio::test]
1995 async fn prune_with_days_zero_is_noop() {
1996 let pool = db::connect_in_memory().await.unwrap();
1997
1998 // Insert a recent health check
1999 let snapshot = HealthSnapshot {
2000 id: None,
2001 target: "mnw".to_string(),
2002 status: HealthStatus::Operational,
2003 checked_at: chrono::Utc::now().to_rfc3339(),
2004 response_time_ms: 100,
2005 details: None,
2006 error: None,
2007 };
2008 db::insert_health_check(&pool, &snapshot).await.unwrap();
2009
2010 // Prune with days=0 should delete nothing
2011 let result = db::prune_old_records(&pool, 0).await.unwrap();
2012 assert_eq!(result.health, 0);
2013 assert_eq!(result.tests, 0);
2014 assert_eq!(result.heartbeats, 0);
2015 assert_eq!(result.alerts, 0);
2016 assert_eq!(result.tls, 0);
2017 assert_eq!(result.incidents, 0);
2018 assert_eq!(result.routes, 0);
2019 assert_eq!(result.dns, 0);
2020 assert_eq!(result.whois, 0);
2021
2022 // Records should still exist
2023 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
2024 assert_eq!(remaining.len(), 1);
2025 }
2026
2027 #[tokio::test]
2028 async fn prune_with_days_seven_keeps_recent() {
2029 let pool = db::connect_in_memory().await.unwrap();
2030
2031 // Insert a health check from yesterday
2032 let yesterday = HealthSnapshot {
2033 id: None,
2034 target: "mnw".to_string(),
2035 status: HealthStatus::Operational,
2036 checked_at: (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(),
2037 response_time_ms: 100,
2038 details: None,
2039 error: None,
2040 };
2041 db::insert_health_check(&pool, &yesterday).await.unwrap();
2042
2043 // Insert a health check from 10 days ago
2044 let old = HealthSnapshot {
2045 id: None,
2046 target: "mnw".to_string(),
2047 status: HealthStatus::Operational,
2048 checked_at: (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(),
2049 response_time_ms: 200,
2050 details: None,
2051 error: None,
2052 };
2053 db::insert_health_check(&pool, &old).await.unwrap();
2054
2055 // Prune with days=7 should only delete the 10-day-old record
2056 let result = db::prune_old_records(&pool, 7).await.unwrap();
2057 assert_eq!(result.health, 1);
2058
2059 // Yesterday's record should remain
2060 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
2061 assert_eq!(remaining.len(), 1);
2062 assert_eq!(remaining[0].response_time_ms, 100);
2063 }
2064
2065 #[tokio::test]
2066 async fn prune_with_days_one_keeps_today() {
2067 let pool = db::connect_in_memory().await.unwrap();
2068
2069 // Insert a health check from now
2070 let today = HealthSnapshot {
2071 id: None,
2072 target: "mnw".to_string(),
2073 status: HealthStatus::Operational,
2074 checked_at: chrono::Utc::now().to_rfc3339(),
2075 response_time_ms: 100,
2076 details: None,
2077 error: None,
2078 };
2079 db::insert_health_check(&pool, &today).await.unwrap();
2080
2081 // Insert a health check from 2 days ago
2082 let old = HealthSnapshot {
2083 id: None,
2084 target: "mnw".to_string(),
2085 status: HealthStatus::Operational,
2086 checked_at: (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339(),
2087 response_time_ms: 200,
2088 details: None,
2089 error: None,
2090 };
2091 db::insert_health_check(&pool, &old).await.unwrap();
2092
2093 // Prune with days=1 should delete the 2-day-old record, keep today's
2094 let result = db::prune_old_records(&pool, 1).await.unwrap();
2095 assert_eq!(result.health, 1);
2096
2097 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
2098 assert_eq!(remaining.len(), 1);
2099 assert_eq!(remaining[0].response_time_ms, 100);
2100 }
2101
2102 #[tokio::test]
2103 async fn prune_counts_cascade_deleted_test_details() {
2104 let pool = db::connect_in_memory().await.unwrap();
2105
2106 let run = TestRun {
2107 id: None,
2108 target: "mnw".to_string(),
2109 started_at: (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(),
2110 finished_at: None,
2111 duration_secs: Some(120),
2112 exit_code: Some(0),
2113 passed: true,
2114 summary: TestSummary {
2115 steps: vec![],
2116 total_passed: Some(3),
2117 total_failed: Some(0),
2118 details: vec![
2119 TestDetail {
2120 test_name: "foo::bar".to_string(),
2121 passed: true,
2122 },
2123 TestDetail {
2124 test_name: "foo::baz".to_string(),
2125 passed: true,
2126 },
2127 TestDetail {
2128 test_name: "foo::qux".to_string(),
2129 passed: true,
2130 },
2131 ],
2132 },
2133 raw_output: String::new(),
2134 filter: None,
2135 };
2136 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
2137 db::insert_test_details(&pool, run_id, &run.summary.details)
2138 .await
2139 .unwrap();
2140
2141 // The ON DELETE CASCADE removes the details along with the run; the count
2142 // must still reflect them rather than reading 0 off an empty orphan sweep.
2143 let result = db::prune_old_records(&pool, 7).await.unwrap();
2144 assert_eq!(result.tests, 1);
2145 assert_eq!(result.test_details, 3);
2146 }
2147
2148 // SSH timeout_secs config test
2149
2150 #[test]
2151 fn ssh_config_timeout_secs_is_parsed() {
2152 let toml = r#"
2153 [targets.mnw]
2154 label = "MakeNotWork"
2155 [targets.mnw.tests]
2156 ssh = "hetzner"
2157 command = "./ci.sh"
2158 timeout_secs = 5
2159 "#;
2160 let config: pom::config::Config = toml::from_str(toml).unwrap();
2161 let tests = config.get_target("mnw").unwrap().tests.as_ref().unwrap();
2162 assert_eq!(tests.timeout_secs, 5);
2163 }
2164
2165 #[test]
2166 fn ssh_config_timeout_secs_default() {
2167 let toml = r#"
2168 [targets.mnw]
2169 label = "MakeNotWork"
2170 [targets.mnw.tests]
2171 ssh = "hetzner"
2172 command = "./ci.sh"
2173 "#;
2174 let config: pom::config::Config = toml::from_str(toml).unwrap();
2175 let tests = config.get_target("mnw").unwrap().tests.as_ref().unwrap();
2176 assert_eq!(tests.timeout_secs, 600); // default
2177 }
2178
2179 // Alert cooldown key consistency test
2180
2181 #[tokio::test]
2182 async fn alert_cooldown_key_matches_across_send_and_check() {
2183 let pool = db::connect_in_memory().await.unwrap();
2184
2185 let config = pom::config::AlertConfig {
2186 postmark_token: None,
2187 to: "test@example.com".to_string(),
2188 from: "PoM Alerts <pom@test.com>".to_string(),
2189 cooldown_secs: 300,
2190 wam_url: None,
2191 wam_token: None,
2192 mnw_url: None,
2193 alerts_ingest_token: None,
2194 };
2195 let alerter = pom::alerts::Alerter::new(config, pool.clone(), "test".to_string()).unwrap();
2196
2197 // Send a health alert for target "example.com"
2198 alerter
2199 .send_health_alert("example.com", "Example", "operational", "error", None)
2200 .await;
2201
2202 // The alert should be recorded with key "health:example.com"
2203 let alert = db::get_latest_alert_for_target(&pool, "health:example.com")
2204 .await
2205 .unwrap();
2206 assert!(
2207 alert.is_some(),
2208 "alert should be recorded with prefixed key"
2209 );
2210
2211 // The old (bare) key should have no record
2212 let bare = db::get_latest_alert_for_target(&pool, "example.com")
2213 .await
2214 .unwrap();
2215 assert!(
2216 bare.is_none(),
2217 "no alert should exist under bare target name"
2218 );
2219 }
2220
2221 // check_health integration tests (mock HTTP server)
2222
2223 #[tokio::test]
2224 async fn check_health_operational_json_response() {
2225 use axum::routing::get;
2226 use pom::checks::http::check_health;
2227 use pom::config::HealthConfig;
2228
2229 let app = axum::Router::new().route(
2230 "/health",
2231 get(|| async {
2232 axum::Json(serde_json::json!({
2233 "status": "operational",
2234 "version": "1.0.0",
2235 "uptime": "2d 5h",
2236 }))
2237 }),
2238 );
2239 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2240 let addr = listener.local_addr().unwrap();
2241 tokio::spawn(async move {
2242 axum::serve(listener, app).await.unwrap();
2243 });
2244
2245 let config = HealthConfig {
2246 url: format!("http://{addr}/health"),
2247 timeout_secs: 5,
2248 interval_secs: None,
2249 expect: None,
2250 trending: None,
2251 };
2252 let snapshot = check_health("test", &config, None).await;
2253 assert_eq!(snapshot.status, HealthStatus::Operational);
2254 assert!(snapshot.response_time_ms >= 0);
2255 let details = snapshot.details.unwrap();
2256 assert_eq!(details.version.as_deref(), Some("1.0.0"));
2257 assert_eq!(details.uptime.as_deref(), Some("2d 5h"));
2258 assert!(snapshot.error.is_none());
2259 }
2260
2261 #[tokio::test]
2262 async fn check_health_degraded_unknown_status() {
2263 use axum::routing::get;
2264 use pom::checks::http::check_health;
2265 use pom::config::HealthConfig;
2266
2267 let app = axum::Router::new().route(
2268 "/health",
2269 get(|| async {
2270 axum::Json(serde_json::json!({
2271 "status": "starting_up",
2272 "version": "1.0.0",
2273 }))
2274 }),
2275 );
2276 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2277 let addr = listener.local_addr().unwrap();
2278 tokio::spawn(async move {
2279 axum::serve(listener, app).await.unwrap();
2280 });
2281
2282 let config = HealthConfig {
2283 url: format!("http://{addr}/health"),
2284 timeout_secs: 5,
2285 interval_secs: None,
2286 expect: None,
2287 trending: None,
2288 };
2289 let snapshot = check_health("test", &config, None).await;
2290 assert_eq!(snapshot.status, HealthStatus::Degraded);
2291 }
2292
2293 #[tokio::test]
2294 async fn check_health_unreachable_target() {
2295 use pom::checks::http::check_health;
2296 use pom::config::HealthConfig;
2297
2298 let config = HealthConfig {
2299 url: "http://127.0.0.1:19999/health".to_string(),
2300 timeout_secs: 1,
2301 interval_secs: None,
2302 expect: None,
2303 trending: None,
2304 };
2305 let snapshot = check_health("test", &config, None).await;
2306 assert_eq!(snapshot.status, HealthStatus::Unreachable);
2307 assert!(snapshot.error.is_some());
2308 }
2309
2310 #[tokio::test]
2311 async fn check_health_with_expectations_passing() {
2312 use axum::routing::get;
2313 use pom::checks::http::check_health;
2314 use pom::config::{HealthConfig, HealthExpectation};
2315
2316 let app = axum::Router::new().route(
2317 "/health",
2318 get(|| async {
2319 axum::Json(serde_json::json!({
2320 "status": "operational",
2321 "version": "1.0.0",
2322 }))
2323 }),
2324 );
2325 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2326 let addr = listener.local_addr().unwrap();
2327 tokio::spawn(async move {
2328 axum::serve(listener, app).await.unwrap();
2329 });
2330
2331 let expect = HealthExpectation {
2332 status_code: Some(200),
2333 json_fields: [("status".to_string(), "operational".to_string())].into(),
2334 body_contains: None,
2335 };
2336 let config = HealthConfig {
2337 url: format!("http://{addr}/health"),
2338 timeout_secs: 5,
2339 interval_secs: None,
2340 expect: Some(expect.clone()),
2341 trending: None,
2342 };
2343 let snapshot = check_health("test", &config, Some(&expect)).await;
2344 assert_eq!(snapshot.status, HealthStatus::Operational);
2345 assert!(snapshot.error.is_none());
2346 }
2347
2348 #[tokio::test]
2349 async fn check_health_with_expectations_failing() {
2350 use axum::routing::get;
2351 use pom::checks::http::check_health;
2352 use pom::config::{HealthConfig, HealthExpectation};
2353
2354 let app = axum::Router::new().route(
2355 "/health",
2356 get(|| async {
2357 axum::Json(serde_json::json!({
2358 "status": "degraded",
2359 "version": "1.0.0",
2360 }))
2361 }),
2362 );
2363 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2364 let addr = listener.local_addr().unwrap();
2365 tokio::spawn(async move {
2366 axum::serve(listener, app).await.unwrap();
2367 });
2368
2369 let expect = HealthExpectation {
2370 status_code: Some(200),
2371 json_fields: [("status".to_string(), "operational".to_string())].into(),
2372 body_contains: None,
2373 };
2374 let config = HealthConfig {
2375 url: format!("http://{addr}/health"),
2376 timeout_secs: 5,
2377 interval_secs: None,
2378 expect: Some(expect.clone()),
2379 trending: None,
2380 };
2381 let snapshot = check_health("test", &config, Some(&expect)).await;
2382 assert_eq!(snapshot.status, HealthStatus::Degraded);
2383 assert!(snapshot.error.is_some());
2384 assert!(snapshot.error.unwrap().contains("expected \"operational\""));
2385 }
2386
2387 // check_tls integration test (self-signed cert)
2388
2389 #[tokio::test]
2390 async fn check_tls_with_test_cert() {
2391 use pom::checks::tls::check_tls;
2392 use pom::config::TlsConfig;
2393 use rcgen::generate_simple_self_signed;
2394 use tokio_rustls::rustls;
2395
2396 // Install crypto provider (tests don't go through main()).
2397 pom::tls::install_crypto_provider();
2398
2399 // Generate a self-signed cert
2400 let subject_alt_names = vec!["localhost".to_string()];
2401 let cert = generate_simple_self_signed(subject_alt_names).unwrap();
2402 let cert_der = cert.cert.der().clone();
2403 let key_der = cert.signing_key.serialize_der();
2404
2405 // Start a TLS server
2406 let server_config = rustls::ServerConfig::builder()
2407 .with_no_client_auth()
2408 .with_single_cert(
2409 vec![rustls_pki_types::CertificateDer::from(cert_der.to_vec())],
2410 rustls_pki_types::PrivateKeyDer::try_from(key_der).unwrap(),
2411 )
2412 .unwrap();
2413
2414 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(server_config));
2415 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2416 let port = listener.local_addr().unwrap().port();
2417
2418 tokio::spawn(async move {
2419 // Accept one connection to prove TLS works
2420 if let Ok((stream, _)) = listener.accept().await {
2421 let _ = acceptor.accept(stream).await;
2422 }
2423 });
2424
2425 let tls_config = TlsConfig {
2426 host: "localhost".to_string(),
2427 port,
2428 warn_days: 14,
2429 };
2430 let result = check_tls("test", &tls_config).await;
2431
2432 // Self-signed cert won't pass webpki validation, so this should return an error
2433 // but it should still complete without panic
2434 assert_eq!(result.target, "test");
2435 assert!(!result.checked_at.is_empty());
2436 // Self-signed cert → error expected (webpki root store doesn't include it)
2437 assert!(result.error.is_some() || result.valid);
2438 }
2439
2440 // API health endpoint test
2441
2442 #[tokio::test]
2443 async fn api_health_endpoint_returns_operational() {
2444 let pool = db::connect_in_memory().await.unwrap();
2445 let config = test_config();
2446 let app = pom::api::router(pool, config, None);
2447
2448 let (status, json) = api_get(&app, "/api/health").await;
2449 assert_eq!(status, 200);
2450 assert_eq!(json["status"], "operational");
2451 assert!(json["version"].as_str().is_some());
2452 }
2453
2454 #[tokio::test]
2455 async fn api_health_endpoint_no_auth_required() {
2456 let pool = db::connect_in_memory().await.unwrap();
2457 // Config with auth token, health endpoint should still be accessible
2458 let mut config = test_config();
2459 config.serve.api_token = Some("secret123".to_string());
2460 let app = pom::api::router(pool, config, None);
2461
2462 // No auth header, but /api/health should still work
2463 let (status, json) = api_get(&app, "/api/health").await;
2464 assert_eq!(status, 200);
2465 assert_eq!(json["status"], "operational");
2466 }
2467
2468 // Rate limit test
2469
2470 #[tokio::test]
2471 async fn api_rate_limit_rejects_excess_requests() {
2472 use pom::api::PerIpRateLimiter;
2473
2474 let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1));
2475 let limiter = PerIpRateLimiter::new(3, std::time::Duration::from_mins(1));
2476
2477 assert!(limiter.try_acquire(ip)); // 1
2478 assert!(limiter.try_acquire(ip)); // 2
2479 assert!(limiter.try_acquire(ip)); // 3
2480 assert!(!limiter.try_acquire(ip)); // 4, should be rejected
2481
2482 // A different client keeps its own budget (SERIOUS #5 isolation).
2483 let other = std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 2));
2484 assert!(limiter.try_acquire(other));
2485 }
2486
2487 // Peer UUID mismatch state reset test
2488
2489 #[tokio::test]
2490 async fn peer_uuid_mismatch_updates_db_identity() {
2491 let pool = db::connect_in_memory().await.unwrap();
2492
2493 // Store initial identity
2494 db::store_peer_identity(&pool, "peer1", "old-uuid")
2495 .await
2496 .unwrap();
2497 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2498 assert_eq!(stored, Some("old-uuid".to_string()));
2499
2500 // Update identity (simulating what happens on UUID mismatch)
2501 db::update_peer_identity(&pool, "peer1", "new-uuid")
2502 .await
2503 .unwrap();
2504 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2505 assert_eq!(stored, Some("new-uuid".to_string()));
2506 }
2507
2508 // DNS check tests
2509
2510 #[tokio::test]
2511 async fn migration_v6_creates_dns_and_whois_tables() {
2512 let pool = db::connect_in_memory().await.unwrap();
2513 let version = db::get_schema_version(&pool).await.unwrap();
2514 assert_eq!(version, 15);
2515
2516 // Verify dns_checks table exists
2517 let dns_result = DnsCheckResult {
2518 target: "mnw".to_string(),
2519 name: "makenot.work".to_string(),
2520 record_type: pom::types::DnsRecordType::A,
2521 expected: vec!["5.78.144.244".to_string()],
2522 actual: vec!["5.78.144.244".to_string()],
2523 matches: true,
2524 checked_at: chrono::Utc::now().to_rfc3339(),
2525 error: None,
2526 };
2527 let id = db::insert_dns_check(&pool, &dns_result).await.unwrap();
2528 assert!(id > 0);
2529
2530 // Verify whois_checks table exists
2531 let whois_result = WhoisResult {
2532 target: "mnw".to_string(),
2533 domain: "makenot.work".to_string(),
2534 registrar: Some("Namecheap, Inc.".to_string()),
2535 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2536 days_remaining: Some(261),
2537 nameservers: vec!["ns1.example.com".to_string()],
2538 checked_at: chrono::Utc::now().to_rfc3339(),
2539 error: None,
2540 };
2541 let id = db::insert_whois_check(&pool, &whois_result).await.unwrap();
2542 assert!(id > 0);
2543 }
2544
2545 #[tokio::test]
2546 async fn dns_check_insert_and_query() {
2547 let pool = db::connect_in_memory().await.unwrap();
2548
2549 let result = DnsCheckResult {
2550 target: "mnw".to_string(),
2551 name: "makenot.work".to_string(),
2552 record_type: pom::types::DnsRecordType::A,
2553 expected: vec!["5.78.144.244".to_string()],
2554 actual: vec!["5.78.144.244".to_string()],
2555 matches: true,
2556 checked_at: "2026-03-15T00:00:00Z".to_string(),
2557 error: None,
2558 };
2559 db::insert_dns_check(&pool, &result).await.unwrap();
2560
2561 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2562 assert_eq!(latest.len(), 1);
2563 assert_eq!(latest[0].name, "makenot.work");
2564 assert_eq!(latest[0].record_type, "A");
2565 assert!(latest[0].matches);
2566 }
2567
2568 #[tokio::test]
2569 async fn dns_check_latest_per_name_and_type() {
2570 let pool = db::connect_in_memory().await.unwrap();
2571
2572 // Insert two checks for same name/type, different times
2573 let r1 = DnsCheckResult {
2574 target: "mnw".to_string(),
2575 name: "makenot.work".to_string(),
2576 record_type: pom::types::DnsRecordType::A,
2577 expected: vec!["1.2.3.4".to_string()],
2578 actual: vec!["5.6.7.8".to_string()],
2579 matches: false,
2580 checked_at: "2026-03-15T00:00:00Z".to_string(),
2581 error: None,
2582 };
2583 let r2 = DnsCheckResult {
2584 target: "mnw".to_string(),
2585 name: "makenot.work".to_string(),
2586 record_type: pom::types::DnsRecordType::A,
2587 expected: vec!["5.78.144.244".to_string()],
2588 actual: vec!["5.78.144.244".to_string()],
2589 matches: true,
2590 checked_at: "2026-03-15T01:00:00Z".to_string(),
2591 error: None,
2592 };
2593 db::insert_dns_check(&pool, &r1).await.unwrap();
2594 db::insert_dns_check(&pool, &r2).await.unwrap();
2595
2596 // Should return only the latest check per name+type
2597 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2598 assert_eq!(latest.len(), 1);
2599 assert!(latest[0].matches);
2600 }
2601
2602 #[tokio::test]
2603 async fn dns_check_multiple_records() {
2604 let pool = db::connect_in_memory().await.unwrap();
2605
2606 let r1 = DnsCheckResult {
2607 target: "mnw".to_string(),
2608 name: "makenot.work".to_string(),
2609 record_type: pom::types::DnsRecordType::A,
2610 expected: vec!["5.78.144.244".to_string()],
2611 actual: vec!["5.78.144.244".to_string()],
2612 matches: true,
2613 checked_at: "2026-03-15T00:00:00Z".to_string(),
2614 error: None,
2615 };
2616 let r2 = DnsCheckResult {
2617 target: "mnw".to_string(),
2618 name: "forums.makenot.work".to_string(),
2619 record_type: pom::types::DnsRecordType::A,
2620 expected: vec!["5.78.144.244".to_string()],
2621 actual: vec!["5.78.144.244".to_string()],
2622 matches: true,
2623 checked_at: "2026-03-15T00:00:00Z".to_string(),
2624 error: None,
2625 };
2626 db::insert_dns_check(&pool, &r1).await.unwrap();
2627 db::insert_dns_check(&pool, &r2).await.unwrap();
2628
2629 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2630 assert_eq!(latest.len(), 2);
2631 }
2632
2633 #[tokio::test]
2634 async fn dns_check_filters_by_target() {
2635 let pool = db::connect_in_memory().await.unwrap();
2636
2637 let r1 = DnsCheckResult {
2638 target: "mnw".to_string(),
2639 name: "makenot.work".to_string(),
2640 record_type: pom::types::DnsRecordType::A,
2641 expected: vec!["5.78.144.244".to_string()],
2642 actual: vec!["5.78.144.244".to_string()],
2643 matches: true,
2644 checked_at: chrono::Utc::now().to_rfc3339(),
2645 error: None,
2646 };
2647 let r2 = DnsCheckResult {
2648 target: "htpy".to_string(),
2649 name: "htpy.app".to_string(),
2650 record_type: pom::types::DnsRecordType::A,
2651 expected: vec!["5.78.135.189".to_string()],
2652 actual: vec!["5.78.135.189".to_string()],
2653 matches: true,
2654 checked_at: chrono::Utc::now().to_rfc3339(),
2655 error: None,
2656 };
2657 db::insert_dns_check(&pool, &r1).await.unwrap();
2658 db::insert_dns_check(&pool, &r2).await.unwrap();
2659
2660 let mnw_checks = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2661 assert_eq!(mnw_checks.len(), 1);
2662 assert_eq!(mnw_checks[0].name, "makenot.work");
2663 }
2664
2665 // WHOIS check tests
2666
2667 #[tokio::test]
2668 async fn whois_check_insert_and_query() {
2669 let pool = db::connect_in_memory().await.unwrap();
2670
2671 let result = WhoisResult {
2672 target: "mnw".to_string(),
2673 domain: "makenot.work".to_string(),
2674 registrar: Some("Namecheap, Inc.".to_string()),
2675 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2676 days_remaining: Some(261),
2677 nameservers: vec!["dns1.registrar-servers.com".to_string()],
2678 checked_at: "2026-03-15T00:00:00Z".to_string(),
2679 error: None,
2680 };
2681 db::insert_whois_check(&pool, &result).await.unwrap();
2682
2683 let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
2684 assert!(latest.is_some());
2685 let row = latest.unwrap();
2686 assert_eq!(row.domain, "makenot.work");
2687 assert_eq!(row.registrar.as_deref(), Some("Namecheap, Inc."));
2688 assert_eq!(row.days_remaining, Some(261));
2689 }
2690
2691 #[tokio::test]
2692 async fn synckit_fleet_check_insert_and_query() {
2693 let pool = db::connect_in_memory().await.unwrap();
2694
2695 let result = SyncKitFleetCheckResult {
2696 target: "mnw".to_string(),
2697 window_days: 30,
2698 devices: 15,
2699 versions: vec![
2700 SyncKitVersionSnapshot {
2701 client_version: Some("0.6.0".to_string()),
2702 devices: 12,
2703 last_seen_at: Some("2026-07-29T12:00:00Z".to_string()),
2704 },
2705 SyncKitVersionSnapshot {
2706 client_version: None,
2707 devices: 3,
2708 last_seen_at: Some("2026-07-20T09:00:00Z".to_string()),
2709 },
2710 ],
2711 checked_at: "2026-07-29T13:00:00Z".to_string(),
2712 error: None,
2713 };
2714 db::insert_synckit_fleet_check(&pool, &result)
2715 .await
2716 .unwrap();
2717
2718 let row = db::get_latest_synckit_fleet_check(&pool, "mnw")
2719 .await
2720 .unwrap()
2721 .expect("readout should be stored");
2722 assert_eq!(row.devices, 15);
2723 assert_eq!(row.window_days, 30);
2724 assert!(row.error.is_none());
2725
2726 let versions = row.version_list();
2727 assert_eq!(versions.len(), 2);
2728 assert_eq!(versions[0].client_version.as_deref(), Some("0.6.0"));
2729 assert_eq!(versions[0].devices, 12);
2730 // The unknown bucket must survive the JSON round trip as null, not as the
2731 // string "unknown": the readout distinguishes "no version reported" from a
2732 // client that literally called itself that.
2733 assert!(versions[1].client_version.is_none());
2734 assert_eq!(versions[1].devices, 3);
2735 }
2736
2737 #[tokio::test]
2738 async fn synckit_fleet_check_returns_latest() {
2739 let pool = db::connect_in_memory().await.unwrap();
2740
2741 for (devices, checked_at) in [(3, "2026-07-28T00:00:00Z"), (9, "2026-07-29T00:00:00Z")] {
2742 let result = SyncKitFleetCheckResult {
2743 target: "mnw".to_string(),
2744 window_days: 30,
2745 devices,
2746 versions: Vec::new(),
2747 checked_at: checked_at.to_string(),
2748 error: None,
2749 };
2750 db::insert_synckit_fleet_check(&pool, &result)
2751 .await
2752 .unwrap();
2753 }
2754
2755 // History is kept rather than overwritten, so the read must be the newest row.
2756 let row = db::get_latest_synckit_fleet_check(&pool, "mnw")
2757 .await
2758 .unwrap()
2759 .unwrap();
2760 assert_eq!(row.devices, 9);
2761 }
2762
2763 #[tokio::test]
2764 async fn synckit_fleet_check_stores_an_unavailable_readout() {
2765 let pool = db::connect_in_memory().await.unwrap();
2766
2767 // A failed readout is persisted, not dropped: otherwise the ledger keeps
2768 // serving the last good distribution and nothing shows that PoM went blind.
2769 let result = SyncKitFleetCheckResult {
2770 target: "mnw".to_string(),
2771 window_days: 0,
2772 devices: 0,
2773 versions: Vec::new(),
2774 checked_at: "2026-07-29T13:00:00Z".to_string(),
2775 error: Some("HTTP 401 (alerts ingest token rejected)".to_string()),
2776 };
2777 db::insert_synckit_fleet_check(&pool, &result)
2778 .await
2779 .unwrap();
2780
2781 let row = db::get_latest_synckit_fleet_check(&pool, "mnw")
2782 .await
2783 .unwrap()
2784 .unwrap();
2785 assert!(row.error.as_deref().unwrap().contains("401"));
2786 assert!(row.version_list().is_empty());
2787 }
2788
2789 #[tokio::test]
2790 async fn synckit_fleet_check_returns_none_for_unknown_target() {
2791 let pool = db::connect_in_memory().await.unwrap();
2792 assert!(
2793 db::get_latest_synckit_fleet_check(&pool, "nope")
2794 .await
2795 .unwrap()
2796 .is_none()
2797 );
2798 }
2799
2800 #[tokio::test]
2801 async fn whois_check_returns_latest() {
2802 let pool = db::connect_in_memory().await.unwrap();
2803
2804 let r1 = WhoisResult {
2805 target: "mnw".to_string(),
2806 domain: "makenot.work".to_string(),
2807 registrar: Some("Old Registrar".to_string()),
2808 expiry_date: Some("2026-06-01T00:00:00Z".to_string()),
2809 days_remaining: Some(78),
2810 nameservers: vec![],
2811 checked_at: "2026-03-15T00:00:00Z".to_string(),
2812 error: None,
2813 };
2814 let r2 = WhoisResult {
2815 target: "mnw".to_string(),
2816 domain: "makenot.work".to_string(),
2817 registrar: Some("New Registrar".to_string()),
2818 expiry_date: Some("2027-06-01T00:00:00Z".to_string()),
2819 days_remaining: Some(443),
2820 nameservers: vec![],
2821 checked_at: "2026-03-15T01:00:00Z".to_string(),
2822 error: None,
2823 };
2824 db::insert_whois_check(&pool, &r1).await.unwrap();
2825 db::insert_whois_check(&pool, &r2).await.unwrap();
2826
2827 let latest = db::get_latest_whois_check(&pool, "mnw")
2828 .await
2829 .unwrap()
2830 .unwrap();
2831 assert_eq!(latest.registrar.as_deref(), Some("New Registrar"));
2832 assert_eq!(latest.days_remaining, Some(443));
2833 }
2834
2835 #[tokio::test]
2836 async fn whois_check_returns_none_for_unknown_target() {
2837 let pool = db::connect_in_memory().await.unwrap();
2838
2839 let latest = db::get_latest_whois_check(&pool, "nonexistent")
2840 .await
2841 .unwrap();
2842 assert!(latest.is_none());
2843 }
2844
2845 #[tokio::test]
2846 async fn whois_check_error_stored() {
2847 let pool = db::connect_in_memory().await.unwrap();
2848
2849 let result = WhoisResult {
2850 target: "mnw".to_string(),
2851 domain: "makenot.work".to_string(),
2852 registrar: None,
2853 expiry_date: None,
2854 days_remaining: None,
2855 nameservers: vec![],
2856 checked_at: chrono::Utc::now().to_rfc3339(),
2857 error: Some("WHOIS connection timed out".to_string()),
2858 };
2859 db::insert_whois_check(&pool, &result).await.unwrap();
2860
2861 let latest = db::get_latest_whois_check(&pool, "mnw")
2862 .await
2863 .unwrap()
2864 .unwrap();
2865 assert_eq!(latest.error.as_deref(), Some("WHOIS connection timed out"));
2866 assert!(latest.registrar.is_none());
2867 }
2868
2869 #[tokio::test]
2870 async fn cors_check_insert_and_query() {
2871 let pool = db::connect_in_memory().await.unwrap();
2872
2873 let result = CorsCheckResult {
2874 target: "mnw".to_string(),
2875 url: "https://storage.example.com/bucket/probe".to_string(),
2876 origin: "https://makenot.work".to_string(),
2877 method: "PUT".to_string(),
2878 passes: true,
2879 checked_at: chrono::Utc::now().to_rfc3339(),
2880 error: None,
2881 };
2882 db::insert_cors_check(&pool, &result).await.unwrap();
2883
2884 let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2885 assert_eq!(latest.len(), 1);
2886 assert!(latest[0].passes);
2887 assert_eq!(latest[0].url, "https://storage.example.com/bucket/probe");
2888 }
2889
2890 #[tokio::test]
2891 async fn cors_check_latest_per_url() {
2892 let pool = db::connect_in_memory().await.unwrap();
2893
2894 // Insert an old failing check
2895 let r1 = CorsCheckResult {
2896 target: "mnw".to_string(),
2897 url: "https://storage.example.com/bucket/probe".to_string(),
2898 origin: "https://makenot.work".to_string(),
2899 method: "PUT".to_string(),
2900 passes: false,
2901 checked_at: "2026-03-01T00:00:00Z".to_string(),
2902 error: Some("Missing Access-Control-Allow-Origin".to_string()),
2903 };
2904 db::insert_cors_check(&pool, &r1).await.unwrap();
2905
2906 // Insert a newer passing check for same URL
2907 let r2 = CorsCheckResult {
2908 target: "mnw".to_string(),
2909 url: "https://storage.example.com/bucket/probe".to_string(),
2910 origin: "https://makenot.work".to_string(),
2911 method: "PUT".to_string(),
2912 passes: true,
2913 checked_at: "2026-03-15T00:00:00Z".to_string(),
2914 error: None,
2915 };
2916 db::insert_cors_check(&pool, &r2).await.unwrap();
2917
2918 let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2919 // Should return only the latest (passing) check per URL
2920 assert_eq!(latest.len(), 1);
2921 assert!(latest[0].passes);
2922 }
2923
2924 #[tokio::test]
2925 async fn cors_check_filters_by_target() {
2926 let pool = db::connect_in_memory().await.unwrap();
2927
2928 let r1 = CorsCheckResult {
2929 target: "mnw".to_string(),
2930 url: "https://storage.example.com/bucket/probe".to_string(),
2931 origin: "https://makenot.work".to_string(),
2932 method: "PUT".to_string(),
2933 passes: true,
2934 checked_at: chrono::Utc::now().to_rfc3339(),
2935 error: None,
2936 };
2937 let r2 = CorsCheckResult {
2938 target: "other".to_string(),
2939 url: "https://other.example.com/probe".to_string(),
2940 origin: "https://other.app".to_string(),
2941 method: "PUT".to_string(),
2942 passes: false,
2943 checked_at: chrono::Utc::now().to_rfc3339(),
2944 error: Some("Failed".to_string()),
2945 };
2946 db::insert_cors_check(&pool, &r1).await.unwrap();
2947 db::insert_cors_check(&pool, &r2).await.unwrap();
2948
2949 let mnw_checks = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2950 assert_eq!(mnw_checks.len(), 1);
2951 assert_eq!(mnw_checks[0].target, "mnw");
2952 }
2953
2954 #[tokio::test]
2955 async fn prune_removes_old_dns_checks() {
2956 let pool = db::connect_in_memory().await.unwrap();
2957
2958 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2959 sqlx::query(
2960 "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at)
2961 VALUES (?, ?, ?, '[]', '[]', 1, ?)",
2962 )
2963 .bind("mnw")
2964 .bind("makenot.work")
2965 .bind("A")
2966 .bind(&old_time)
2967 .execute(&pool)
2968 .await
2969 .unwrap();
2970
2971 // Insert recent DNS check
2972 let recent = DnsCheckResult {
2973 target: "mnw".to_string(),
2974 name: "makenot.work".to_string(),
2975 record_type: pom::types::DnsRecordType::A,
2976 expected: vec![],
2977 actual: vec![],
2978 matches: true,
2979 checked_at: chrono::Utc::now().to_rfc3339(),
2980 error: None,
2981 };
2982 db::insert_dns_check(&pool, &recent).await.unwrap();
2983
2984 let result = db::prune_old_records(&pool, 30).await.unwrap();
2985 assert_eq!(result.dns, 1);
2986
2987 let remaining = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2988 assert_eq!(remaining.len(), 1);
2989 }
2990
2991 #[tokio::test]
2992 async fn prune_removes_old_whois_checks() {
2993 let pool = db::connect_in_memory().await.unwrap();
2994
2995 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2996 sqlx::query("INSERT INTO whois_checks (target, domain, checked_at) VALUES (?, ?, ?)")
2997 .bind("mnw")
2998 .bind("makenot.work")
2999 .bind(&old_time)
3000 .execute(&pool)
3001 .await
3002 .unwrap();
3003
3004 // Insert recent WHOIS check
3005 let recent = WhoisResult {
3006 target: "mnw".to_string(),
3007 domain: "makenot.work".to_string(),
3008 registrar: None,
3009 expiry_date: None,
3010 days_remaining: None,
3011 nameservers: vec![],
3012 checked_at: chrono::Utc::now().to_rfc3339(),
3013 error: None,
3014 };
3015 db::insert_whois_check(&pool, &recent).await.unwrap();
3016
3017 let result = db::prune_old_records(&pool, 30).await.unwrap();
3018 assert_eq!(result.whois, 1);
3019
3020 let remaining = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
3021 assert!(remaining.is_some());
3022 }
3023
3024 // DNS/WHOIS API tests
3025
3026 #[tokio::test]
3027 async fn api_status_includes_dns_status() {
3028 let pool = db::connect_in_memory().await.unwrap();
3029 let config: pom::config::Config = toml::from_str(
3030 r#"
3031 [targets.mnw]
3032 label = "MakeNotWork"
3033 [targets.mnw.health]
3034 url = "https://makenot.work/health"
3035 [[targets.mnw.dns]]
3036 name = "makenot.work"
3037 record_type = "A"
3038 expected = ["5.78.144.244"]
3039 "#,
3040 )
3041 .unwrap();
3042 let app = pom::api::router(pool.clone(), config, None);
3043
3044 // Insert DNS check data
3045 let dns_result = DnsCheckResult {
3046 target: "mnw".to_string(),
3047 name: "makenot.work".to_string(),
3048 record_type: pom::types::DnsRecordType::A,
3049 expected: vec!["5.78.144.244".to_string()],
3050 actual: vec!["5.78.144.244".to_string()],
3051 matches: true,
3052 checked_at: chrono::Utc::now().to_rfc3339(),
3053 error: None,
3054 };
3055 db::insert_dns_check(&pool, &dns_result).await.unwrap();
3056
3057 let (status, json) = api_get(&app, "/api/status/mnw").await;
3058 assert_eq!(status, 200);
3059 let dns = json["dns_status"].as_array().unwrap();
3060 assert_eq!(dns.len(), 1);
3061 assert_eq!(dns[0]["name"], "makenot.work");
3062 assert_eq!(dns[0]["matches"], true);
3063 }
3064
3065 #[tokio::test]
3066 async fn api_status_omits_empty_dns_status() {
3067 let pool = db::connect_in_memory().await.unwrap();
3068 let config = test_config();
3069 let app = pom::api::router(pool, config, None);
3070
3071 let (status, json) = api_get(&app, "/api/status/mnw").await;
3072 assert_eq!(status, 200);
3073 assert!(json.get("dns_status").is_none());
3074 }
3075
3076 #[tokio::test]
3077 async fn api_status_includes_whois() {
3078 let pool = db::connect_in_memory().await.unwrap();
3079 let config = test_config();
3080 let app = pom::api::router(pool.clone(), config, None);
3081
3082 let whois_result = WhoisResult {
3083 target: "mnw".to_string(),
3084 domain: "makenot.work".to_string(),
3085 registrar: Some("Namecheap, Inc.".to_string()),
3086 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
3087 days_remaining: Some(261),
3088 nameservers: vec!["ns1.example.com".to_string()],
3089 checked_at: chrono::Utc::now().to_rfc3339(),
3090 error: None,
3091 };
3092 db::insert_whois_check(&pool, &whois_result).await.unwrap();
3093
3094 let (status, json) = api_get(&app, "/api/status/mnw").await;
3095 assert_eq!(status, 200);
3096 assert!(json["whois"].is_object());
3097 assert_eq!(json["whois"]["domain"], "makenot.work");
3098 assert_eq!(json["whois"]["days_remaining"], 261);
3099 }
3100
3101 #[tokio::test]
3102 async fn api_status_omits_whois_when_none() {
3103 let pool = db::connect_in_memory().await.unwrap();
3104 let config = test_config();
3105 let app = pom::api::router(pool, config, None);
3106
3107 let (status, json) = api_get(&app, "/api/status/mnw").await;
3108 assert_eq!(status, 200);
3109 assert!(json.get("whois").is_none());
3110 }
3111
3112 // DNS/WHOIS config parsing tests
3113
3114 #[test]
3115 fn config_with_dns_records_parses() {
3116 let toml_str = r#"
3117 [targets.mnw]
3118 label = "MakeNotWork"
3119
3120 [[targets.mnw.dns]]
3121 name = "makenot.work"
3122 record_type = "A"
3123 expected = ["5.78.144.244"]
3124
3125 [[targets.mnw.dns]]
3126 name = "forums.makenot.work"
3127 record_type = "A"
3128 expected = ["5.78.144.244"]
3129 "#;
3130 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3131 let mnw = config.get_target("mnw").unwrap();
3132 assert_eq!(mnw.dns.len(), 2);
3133 assert_eq!(mnw.dns[0].name, "makenot.work");
3134 assert_eq!(mnw.dns[0].record_type, pom::types::DnsRecordType::A);
3135 assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
3136 assert_eq!(mnw.dns[1].name, "forums.makenot.work");
3137 }
3138
3139 #[test]
3140 fn config_with_whois_parses() {
3141 let toml_str = r#"
3142 [targets.mnw]
3143 label = "MakeNotWork"
3144
3145 [targets.mnw.whois]
3146 domain = "makenot.work"
3147 warn_days = 60
3148 "#;
3149 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3150 let mnw = config.get_target("mnw").unwrap();
3151 let whois = mnw.whois.as_ref().unwrap();
3152 assert_eq!(whois.domain, "makenot.work");
3153 assert_eq!(whois.warn_days, 60);
3154 }
3155
3156 #[test]
3157 fn config_whois_default_warn_days() {
3158 let toml_str = r#"
3159 [targets.mnw]
3160 label = "MakeNotWork"
3161
3162 [targets.mnw.whois]
3163 domain = "makenot.work"
3164 "#;
3165 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3166 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
3167 assert_eq!(whois.warn_days, 30);
3168 }
3169
3170 #[test]
3171 fn config_with_cors_parses() {
3172 let toml_str = r#"
3173 [targets.mnw]
3174 label = "MakeNotWork"
3175
3176 [[targets.mnw.cors]]
3177 url = "https://example.com/bucket/probe"
3178 origin = "https://myapp.com"
3179 method = "PUT"
3180
3181 [[targets.mnw.cors]]
3182 url = "https://example.com/bucket/probe2"
3183 origin = "https://myapp.com"
3184 "#;
3185 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3186 let mnw = config.get_target("mnw").unwrap();
3187 assert_eq!(mnw.cors.len(), 2);
3188 assert_eq!(mnw.cors[0].url, "https://example.com/bucket/probe");
3189 assert_eq!(mnw.cors[0].origin, "https://myapp.com");
3190 assert_eq!(mnw.cors[0].method, "PUT");
3191 // Second entry uses default method
3192 assert_eq!(mnw.cors[1].method, "PUT");
3193 }
3194
3195 #[test]
3196 fn config_no_cors_defaults_to_empty() {
3197 let toml_str = r#"
3198 [targets.mnw]
3199 label = "MakeNotWork"
3200 "#;
3201 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3202 let mnw = config.get_target("mnw").unwrap();
3203 assert!(mnw.cors.is_empty());
3204 }
3205
3206 #[test]
3207 fn config_no_dns_defaults_to_empty() {
3208 let toml_str = r#"
3209 [targets.mnw]
3210 label = "MakeNotWork"
3211 "#;
3212 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3213 let mnw = config.get_target("mnw").unwrap();
3214 assert!(mnw.dns.is_empty());
3215 assert!(mnw.whois.is_none());
3216 }
3217
3218 // Dashboard tests
3219
3220 #[tokio::test]
3221 async fn dashboard_enabled_serves_html() {
3222 let pool = db::connect_in_memory().await.unwrap();
3223 let mut config = test_config();
3224 config.serve.dashboard = true;
3225 let app = pom::api::router(pool, config, None);
3226
3227 let (status, body) = get_body(&app, "/").await;
3228 assert_eq!(status, 200);
3229 assert!(body.contains("<!DOCTYPE html>"), "should contain doctype");
3230 assert!(body.contains("PoM"), "should contain PoM title");
3231 }
3232
3233 #[tokio::test]
3234 async fn dashboard_disabled_returns_404() {
3235 let pool = db::connect_in_memory().await.unwrap();
3236 let config = test_config(); // dashboard defaults to false
3237 let app = pom::api::router(pool, config, None);
3238
3239 let resp = app.clone().oneshot(get_req("/")).await.unwrap();
3240 assert_eq!(resp.status().as_u16(), 404);
3241 }
3242
3243 #[tokio::test]
3244 async fn dashboard_does_not_embed_api_token() {
3245 // SERIOUS #4: the served dashboard page must never contain the api_token,
3246 // it authenticates via the httpOnly pom_dash cookie instead.
3247 let pool = db::connect_in_memory().await.unwrap();
3248 let mut config = test_config();
3249 config.serve.dashboard = true;
3250 config.serve.api_token = Some("test-secret-token-42".to_string());
3251 let app = pom::api::router(pool, config, None);
3252
3253 let (status, body) = get_body(&app, "/").await;
3254 assert_eq!(status, 200);
3255 assert!(
3256 !body.contains("test-secret-token-42"),
3257 "the api_token must not appear in the page"
3258 );
3259 assert!(
3260 !body.contains("API_TOKEN"),
3261 "no token constant should be embedded in the JS"
3262 );
3263 }
3264
3265 #[tokio::test]
3266 async fn dashboard_shows_mesh_section_when_mesh_enabled() {
3267 let pool = db::connect_in_memory().await.unwrap();
3268 let mut config = test_config();
3269 config.serve.dashboard = true;
3270 let mesh = test_mesh();
3271 let app = pom::api::router(pool, config, Some(mesh));
3272
3273 let (status, body) = get_body(&app, "/").await;
3274 assert_eq!(status, 200);
3275 assert!(
3276 body.contains("Peer Mesh"),
3277 "should contain mesh section title"
3278 );
3279 assert!(
3280 body.contains("HAS_MESH = true"),
3281 "should set HAS_MESH to true"
3282 );
3283 }
3284
3285 #[tokio::test]
3286 async fn dashboard_no_mesh_section_when_mesh_disabled() {
3287 let pool = db::connect_in_memory().await.unwrap();
3288 let mut config = test_config();
3289 config.serve.dashboard = true;
3290 let app = pom::api::router(pool, config, None);
3291
3292 let (status, body) = get_body(&app, "/").await;
3293 assert_eq!(status, 200);
3294 assert!(
3295 body.contains("HAS_MESH = false"),
3296 "should set HAS_MESH to false"
3297 );
3298 }
3299
3300 // Per-test detail tracking
3301
3302 #[tokio::test]
3303 async fn insert_and_query_test_details() {
3304 let pool = db::connect_in_memory().await.unwrap();
3305
3306 let run = TestRun {
3307 id: None,
3308 target: "mnw".to_string(),
3309 started_at: "2026-03-16T00:00:00Z".to_string(),
3310 finished_at: Some("2026-03-16T00:02:00Z".to_string()),
3311 duration_secs: Some(120),
3312 exit_code: Some(0),
3313 passed: true,
3314 summary: TestSummary {
3315 steps: vec![],
3316 total_passed: Some(3),
3317 total_failed: Some(0),
3318 details: vec![
3319 TestDetail {
3320 test_name: "foo::bar".to_string(),
3321 passed: true,
3322 },
3323 TestDetail {
3324 test_name: "foo::baz".to_string(),
3325 passed: true,
3326 },
3327 TestDetail {
3328 test_name: "foo::qux".to_string(),
3329 passed: true,
3330 },
3331 ],
3332 },
3333 raw_output: String::new(),
3334 filter: None,
3335 };
3336
3337 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3338 db::insert_test_details(&pool, run_id, &run.summary.details)
3339 .await
3340 .unwrap();
3341
3342 // Verify via regression detection (no previous run = no regressions)
3343 let regressions = db::get_test_regressions(&pool, "mnw", run_id)
3344 .await
3345 .unwrap();
3346 assert!(regressions.is_empty());
3347 }
3348
3349 #[tokio::test]
3350 async fn regression_detection_finds_newly_failing_tests() {
3351 let pool = db::connect_in_memory().await.unwrap();
3352
3353 // First run: all pass
3354 let run1 = TestRun {
3355 id: None,
3356 target: "mnw".to_string(),
3357 started_at: "2026-03-16T00:00:00Z".to_string(),
3358 finished_at: Some("2026-03-16T00:02:00Z".to_string()),
3359 duration_secs: Some(120),
3360 exit_code: Some(0),
3361 passed: true,
3362 summary: TestSummary {
3363 steps: vec![],
3364 total_passed: Some(3),
3365 total_failed: Some(0),
3366 details: vec![
3367 TestDetail {
3368 test_name: "foo::bar".to_string(),
3369 passed: true,
3370 },
3371 TestDetail {
3372 test_name: "foo::baz".to_string(),
3373 passed: true,
3374 },
3375 TestDetail {
3376 test_name: "foo::qux".to_string(),
3377 passed: true,
3378 },
3379 ],
3380 },
3381 raw_output: String::new(),
3382 filter: None,
3383 };
3384
3385 let run1_id = db::insert_test_run(&pool, &run1).await.unwrap();
3386 db::insert_test_details(&pool, run1_id, &run1.summary.details)
3387 .await
3388 .unwrap();
3389
3390 // Second run: foo::baz fails
3391 let run2 = TestRun {
3392 id: None,
3393 target: "mnw".to_string(),
3394 started_at: "2026-03-16T00:05:00Z".to_string(),
3395 finished_at: Some("2026-03-16T00:07:00Z".to_string()),
3396 duration_secs: Some(120),
3397 exit_code: Some(1),
3398 passed: false,
3399 summary: TestSummary {
3400 steps: vec![],
3401 total_passed: Some(2),
3402 total_failed: Some(1),
3403 details: vec![
3404 TestDetail {
3405 test_name: "foo::bar".to_string(),
3406 passed: true,
3407 },
3408 TestDetail {
3409 test_name: "foo::baz".to_string(),
3410 passed: false,
3411 },
3412 TestDetail {
3413 test_name: "foo::qux".to_string(),
3414 passed: true,
3415 },
3416 ],
3417 },
3418 raw_output: String::new(),
3419 filter: None,
3420 };
3421
3422 let run2_id = db::insert_test_run(&pool, &run2).await.unwrap();
3423 db::insert_test_details(&pool, run2_id, &run2.summary.details)
3424 .await
3425 .unwrap();
3426
3427 let regressions = db::get_test_regressions(&pool, "mnw", run2_id)
3428 .await
3429 .unwrap();
3430 assert_eq!(regressions.len(), 1);
3431 assert_eq!(regressions[0], "foo::baz");
3432 }
3433
3434 #[tokio::test]
3435 async fn regression_ignores_already_failing_tests() {
3436 let pool = db::connect_in_memory().await.unwrap();
3437
3438 // Both runs: foo::baz fails
3439 for (i, ts) in ["00:00:00", "00:05:00"].iter().enumerate() {
3440 let run = TestRun {
3441 id: None,
3442 target: "mnw".to_string(),
3443 started_at: format!("2026-03-16T{ts}Z"),
3444 finished_at: None,
3445 duration_secs: Some(120),
3446 exit_code: Some(1),
3447 passed: false,
3448 summary: TestSummary {
3449 steps: vec![],
3450 total_passed: Some(2),
3451 total_failed: Some(1),
3452 details: vec![
3453 TestDetail {
3454 test_name: "foo::bar".to_string(),
3455 passed: true,
3456 },
3457 TestDetail {
3458 test_name: "foo::baz".to_string(),
3459 passed: false,
3460 },
3461 ],
3462 },
3463 raw_output: String::new(),
3464 filter: None,
3465 };
3466
3467 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3468 db::insert_test_details(&pool, run_id, &run.summary.details)
3469 .await
3470 .unwrap();
3471
3472 if i == 1 {
3473 // Second run, baz was already failing, not a regression
3474 let regressions = db::get_test_regressions(&pool, "mnw", run_id)
3475 .await
3476 .unwrap();
3477 assert!(regressions.is_empty());
3478 }
3479 }
3480 }
3481
3482 // Test duration drift detection
3483
3484 #[tokio::test]
3485 async fn test_duration_drift_detected() {
3486 use pom::checks::drift::detect_test_duration_drift;
3487
3488 // 10 baseline runs at 60s, 3 recent runs at 120s (2x baseline > 1.5x threshold)
3489 let mut durations: Vec<(String, i64)> = Vec::new();
3490 // Most recent first
3491 for i in 0..3 {
3492 durations.push((format!("2026-03-16T00:{:02}:00Z", 12 - i), 120));
3493 }
3494 for i in 0..10 {
3495 durations.push((format!("2026-03-16T00:{:02}:00Z", 9 - i), 60));
3496 }
3497
3498 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3499 assert!(drift.is_some());
3500 let msg = drift.unwrap();
3501 assert!(msg.contains("drift"), "drift message: {msg}");
3502 }
3503
3504 #[tokio::test]
3505 async fn test_duration_no_drift_when_stable() {
3506 use pom::checks::drift::detect_test_duration_drift;
3507
3508 // All runs at ~60s
3509 let mut durations: Vec<(String, i64)> = Vec::new();
3510 for i in 0..13 {
3511 durations.push((format!("2026-03-16T00:{:02}:00Z", 12 - i), 60));
3512 }
3513
3514 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3515 assert!(drift.is_none());
3516 }
3517
3518 #[tokio::test]
3519 async fn test_duration_drift_not_enough_data() {
3520 use pom::checks::drift::detect_test_duration_drift;
3521
3522 // Only 5 runs (need 13 for baseline 10 + recent 3)
3523 let durations: Vec<(String, i64)> = (0..5)
3524 .map(|i| (format!("2026-03-16T00:{i:02}:00Z"), 120))
3525 .collect();
3526
3527 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3528 assert!(drift.is_none());
3529 }
3530
3531 #[tokio::test]
3532 async fn get_test_durations_returns_ordered() {
3533 let pool = db::connect_in_memory().await.unwrap();
3534
3535 for (i, secs) in [60, 80, 100].iter().enumerate() {
3536 let run = TestRun {
3537 id: None,
3538 target: "mnw".to_string(),
3539 started_at: format!("2026-03-16T00:{i:02}:00Z"),
3540 finished_at: None,
3541 duration_secs: Some(*secs),
3542 exit_code: Some(0),
3543 passed: true,
3544 summary: TestSummary {
3545 steps: vec![],
3546 total_passed: None,
3547 total_failed: None,
3548 details: vec![],
3549 },
3550 raw_output: String::new(),
3551 filter: None,
3552 };
3553 db::insert_test_run(&pool, &run).await.unwrap();
3554 }
3555
3556 let durations = db::get_test_durations(&pool, "mnw", 10).await.unwrap();
3557 assert_eq!(durations.len(), 3);
3558 // Most recent first
3559 assert_eq!(durations[0].1, 100);
3560 assert_eq!(durations[2].1, 60);
3561 }
3562
3563 // Uptime percent tests
3564
3565 #[tokio::test]
3566 async fn uptime_percent_all_operational() {
3567 let pool = db::connect_in_memory().await.unwrap();
3568
3569 for i in 0..10 {
3570 let snapshot = HealthSnapshot {
3571 id: None,
3572 target: "mnw".to_string(),
3573 status: HealthStatus::Operational,
3574 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3575 response_time_ms: 100,
3576 details: None,
3577 error: None,
3578 };
3579 db::insert_health_check(&pool, &snapshot).await.unwrap();
3580 }
3581
3582 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3583 assert_eq!(pct, Some(100.0));
3584 }
3585
3586 #[tokio::test]
3587 async fn uptime_percent_mixed() {
3588 let pool = db::connect_in_memory().await.unwrap();
3589
3590 // 8 operational
3591 for i in 0..8 {
3592 let snapshot = HealthSnapshot {
3593 id: None,
3594 target: "mnw".to_string(),
3595 status: HealthStatus::Operational,
3596 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3597 response_time_ms: 100,
3598 details: None,
3599 error: None,
3600 };
3601 db::insert_health_check(&pool, &snapshot).await.unwrap();
3602 }
3603
3604 // 2 error
3605 for i in 8..10 {
3606 let snapshot = HealthSnapshot {
3607 id: None,
3608 target: "mnw".to_string(),
3609 status: HealthStatus::Error,
3610 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3611 response_time_ms: 0,
3612 details: None,
3613 error: Some("down".to_string()),
3614 };
3615 db::insert_health_check(&pool, &snapshot).await.unwrap();
3616 }
3617
3618 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3619 assert!(pct.is_some());
3620 let p = pct.unwrap();
3621 assert!((p - 80.0).abs() < 0.01, "expected ~80.0, got {p}");
3622 }
3623
3624 #[tokio::test]
3625 async fn uptime_percent_no_data() {
3626 let pool = db::connect_in_memory().await.unwrap();
3627
3628 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3629 assert_eq!(pct, None);
3630 }
3631
3632 #[tokio::test]
3633 async fn uptime_percent_only_old_data() {
3634 let pool = db::connect_in_memory().await.unwrap();
3635
3636 // Insert checks from 48 hours ago
3637 for i in 0..5 {
3638 let snapshot = HealthSnapshot {
3639 id: None,
3640 target: "mnw".to_string(),
3641 status: HealthStatus::Operational,
3642 checked_at: (chrono::Utc::now() - chrono::Duration::hours(48 + i)).to_rfc3339(),
3643 response_time_ms: 100,
3644 details: None,
3645 error: None,
3646 };
3647 db::insert_health_check(&pool, &snapshot).await.unwrap();
3648 }
3649
3650 // Query last 24h, should find nothing
3651 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3652 assert_eq!(pct, None);
3653 }
3654
3655 // Prune test_details orphan cleanup
3656
3657 #[tokio::test]
3658 async fn prune_cascades_test_details_with_deleted_run() {
3659 let pool = db::connect_in_memory().await.unwrap();
3660
3661 // Insert an old test run with details
3662 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
3663 let run = TestRun {
3664 id: None,
3665 target: "mnw".to_string(),
3666 started_at: old_time,
3667 finished_at: None,
3668 duration_secs: Some(60),
3669 exit_code: Some(0),
3670 passed: true,
3671 summary: TestSummary {
3672 steps: vec![],
3673 total_passed: Some(2),
3674 total_failed: Some(0),
3675 details: vec![
3676 TestDetail {
3677 test_name: "test_a".to_string(),
3678 passed: true,
3679 },
3680 TestDetail {
3681 test_name: "test_b".to_string(),
3682 passed: true,
3683 },
3684 ],
3685 },
3686 raw_output: String::new(),
3687 filter: None,
3688 };
3689 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3690 db::insert_test_details(&pool, run_id, &run.summary.details)
3691 .await
3692 .unwrap();
3693
3694 // Verify details exist
3695 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3696 .bind(run_id.0)
3697 .fetch_one(&pool)
3698 .await
3699 .unwrap();
3700 assert_eq!(count.0, 2);
3701
3702 // Prune with 1-day retention, the old run gets deleted, CASCADE removes details
3703 let result = db::prune_old_records(&pool, 1).await.unwrap();
3704 assert_eq!(result.tests, 1);
3705
3706 // Verify details are gone (removed by ON DELETE CASCADE)
3707 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3708 .bind(run_id.0)
3709 .fetch_one(&pool)
3710 .await
3711 .unwrap();
3712 assert_eq!(count.0, 0);
3713 }
3714
3715 #[tokio::test]
3716 async fn prune_cleans_up_orphaned_test_details() {
3717 let pool = db::connect_in_memory().await.unwrap();
3718
3719 // Create a real test run, add details, then delete the run directly to create orphans.
3720 // (FK constraints prevent inserting with a non-existent run_id.)
3721 let run = TestRun {
3722 id: None,
3723 target: "mnw".to_string(),
3724 started_at: chrono::Utc::now().to_rfc3339(),
3725 finished_at: None,
3726 duration_secs: Some(60),
3727 exit_code: Some(0),
3728 passed: true,
3729 summary: TestSummary {
3730 steps: vec![],
3731 total_passed: Some(1),
3732 total_failed: Some(0),
3733 details: vec![TestDetail {
3734 test_name: "orphan_test".to_string(),
3735 passed: true,
3736 }],
3737 },
3738 raw_output: String::new(),
3739 filter: None,
3740 };
3741 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3742 db::insert_test_details(&pool, run_id, &run.summary.details)
3743 .await
3744 .unwrap();
3745
3746 // Disable FK enforcement temporarily to delete the run without cascading
3747 sqlx::query("PRAGMA foreign_keys = OFF")
3748 .execute(&pool)
3749 .await
3750 .unwrap();
3751 sqlx::query("DELETE FROM test_runs WHERE id = ?")
3752 .bind(run_id.0)
3753 .execute(&pool)
3754 .await
3755 .unwrap();
3756 sqlx::query("PRAGMA foreign_keys = ON")
3757 .execute(&pool)
3758 .await
3759 .unwrap();
3760
3761 // Verify orphan exists
3762 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3763 .bind(run_id.0)
3764 .fetch_one(&pool)
3765 .await
3766 .unwrap();
3767 assert_eq!(count.0, 1);
3768
3769 // Prune, orphaned details should be cleaned up by the explicit orphan SQL
3770 let result = db::prune_old_records(&pool, 30).await.unwrap();
3771 assert_eq!(result.test_details, 1);
3772
3773 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3774 .bind(run_id.0)
3775 .fetch_one(&pool)
3776 .await
3777 .unwrap();
3778 assert_eq!(count.0, 0);
3779 }
3780
3781 // API test_duration_drift field
3782
3783 #[tokio::test]
3784 async fn api_status_target_includes_test_duration_drift() {
3785 let pool = db::connect_in_memory().await.unwrap();
3786 let config = test_config_with_tests();
3787 let app = pom::api::router(pool.clone(), config, None);
3788
3789 // Insert 13 test runs: 10 baseline at 60s, 3 recent at 120s
3790 for i in 0..10 {
3791 let run = TestRun {
3792 id: None,
3793 target: "mnw".to_string(),
3794 started_at: format!("2026-03-16T{i:02}:00:00Z"),
3795 finished_at: None,
3796 duration_secs: Some(60),
3797 exit_code: Some(0),
3798 passed: true,
3799 summary: TestSummary {
3800 steps: vec![],
3801 total_passed: None,
3802 total_failed: None,
3803 details: vec![],
3804 },
3805 raw_output: String::new(),
3806 filter: None,
3807 };
3808 db::insert_test_run(&pool, &run).await.unwrap();
3809 }
3810 for i in 10..13 {
3811 let run = TestRun {
3812 id: None,
3813 target: "mnw".to_string(),
3814 started_at: format!("2026-03-16T{i:02}:00:00Z"),
3815 finished_at: None,
3816 duration_secs: Some(120),
3817 exit_code: Some(0),
3818 passed: true,
3819 summary: TestSummary {
3820 steps: vec![],
3821 total_passed: None,
3822 total_failed: None,
3823 details: vec![],
3824 },
3825 raw_output: String::new(),
3826 filter: None,
3827 };
3828 db::insert_test_run(&pool, &run).await.unwrap();
3829 }
3830
3831 let (status, json) = api_get(&app, "/api/status/mnw").await;
3832 assert_eq!(status, 200);
3833 assert!(
3834 json["test_duration_drift"].is_string(),
3835 "expected test_duration_drift string, got: {json}"
3836 );
3837 let drift_msg = json["test_duration_drift"].as_str().unwrap();
3838 assert!(drift_msg.contains("drift"), "drift message: {drift_msg}");
3839 }
3840
3841 // pom versions roll-up
3842
3843 /// Insert one health check carrying the given version/sha for a target.
3844 async fn insert_version_health(
3845 pool: &sqlx::SqlitePool,
3846 target: &str,
3847 version: Option<&str>,
3848 git_sha: Option<&str>,
3849 checked_at: &str,
3850 ) {
3851 let snapshot = HealthSnapshot {
3852 id: None,
3853 target: target.to_string(),
3854 status: HealthStatus::Operational,
3855 checked_at: checked_at.to_string(),
3856 response_time_ms: 100,
3857 details: Some(HealthDetails {
3858 version: version.map(String::from),
3859 git_sha: git_sha.map(String::from),
3860 uptime: None,
3861 checks: None,
3862 monitoring: None,
3863 }),
3864 error: None,
3865 };
3866 db::insert_health_check(pool, &snapshot).await.unwrap();
3867 }
3868
3869 /// A throwaway git repo with `commits` commits on HEAD, each touching
3870 /// `subdir/f` when a subdir is given and a top-level file otherwise. Returns
3871 /// the repo path and every commit sha, oldest first.
3872 fn scratch_repo(
3873 name: &str,
3874 commits: usize,
3875 subdir: Option<&str>,
3876 ) -> (std::path::PathBuf, Vec<String>) {
3877 use std::process::Command;
3878
3879 let path = std::env::temp_dir().join(format!("pom_versions_{}_{name}", std::process::id()));
3880 let _ = std::fs::remove_dir_all(&path);
3881 std::fs::create_dir_all(&path).unwrap();
3882
3883 let git = |args: &[&str]| {
3884 let out = Command::new("git")
3885 .arg("-C")
3886 .arg(&path)
3887 .args(args)
3888 .output()
3889 .unwrap();
3890 assert!(
3891 out.status.success(),
3892 "git {args:?}: {}",
3893 String::from_utf8_lossy(&out.stderr)
3894 );
3895 String::from_utf8_lossy(&out.stdout).trim().to_string()
3896 };
3897
3898 git(&["init", "-b", "main"]);
3899 git(&["config", "user.email", "test@example.invalid"]);
3900 git(&["config", "user.name", "pom test"]);
3901
3902 let mut shas = Vec::new();
3903 for i in 0..commits {
3904 let file = match subdir {
3905 Some(d) => {
3906 std::fs::create_dir_all(path.join(d)).unwrap();
3907 path.join(d).join("f")
3908 }
3909 None => path.join("f"),
3910 };
3911 std::fs::write(&file, format!("{i}\n")).unwrap();
3912 git(&["add", "-A"]);
3913 git(&["commit", "-m", &format!("commit {i}")]);
3914 shas.push(git(&["rev-parse", "HEAD"]));
3915 }
3916
3917 (path, shas)
3918 }
3919
3920 #[tokio::test]
3921 async fn versions_rollup_reads_the_latest_health_per_target() {
3922 let pool = db::connect_in_memory().await.unwrap();
3923 let config: pom::config::Config = toml::from_str(
3924 r#"
3925 [targets.mnw]
3926 label = "MakeNotWork"
3927 [targets.mt]
3928 label = "Multithreaded"
3929 "#,
3930 )
3931 .unwrap();
3932
3933 insert_version_health(
3934 &pool,
3935 "mnw",
3936 Some("0.10.0"),
3937 Some("aaaa1111"),
3938 "2026-07-28T00:00:00Z",
3939 )
3940 .await;
3941 insert_version_health(
3942 &pool,
3943 "mnw",
3944 Some("0.11.0"),
3945 Some("6402bf4e"),
3946 "2026-07-29T00:00:00Z",
3947 )
3948 .await;
3949
3950 let rows = pom::versions::collect(&pool, &config).await.unwrap();
3951 assert_eq!(rows.len(), 2);
3952
3953 let mnw = rows.iter().find(|r| r.target == "mnw").unwrap();
3954 assert_eq!(mnw.label, "MakeNotWork");
3955 assert_eq!(mnw.version.as_deref(), Some("0.11.0"));
3956 assert_eq!(mnw.git_sha.as_deref(), Some("6402bf4e"));
3957 assert_eq!(mnw.checked_at.as_deref(), Some("2026-07-29T00:00:00Z"));
3958
3959 // Never checked, and no repo configured: blank, and not an error.
3960 let mt = rows.iter().find(|r| r.target == "mt").unwrap();
3961 assert!(mt.version.is_none() && mt.checked_at.is_none());
3962 assert!(mt.commits_behind.is_none() && mt.behind_error.is_none());
3963 }
3964
3965 #[tokio::test]
3966 async fn versions_counts_commits_behind_local_head() {
3967 let (path, shas) = scratch_repo("behind", 3, None);
3968 let pool = db::connect_in_memory().await.unwrap();
3969 let config: pom::config::Config = toml::from_str(&format!(
3970 r#"
3971 [targets.mnw]
3972 label = "MakeNotWork"
3973 [targets.mnw.repo]
3974 path = "{}"
3975 "#,
3976 path.display()
3977 ))
3978 .unwrap();
3979
3980 insert_version_health(
3981 &pool,
3982 "mnw",
3983 Some("0.11.0"),
3984 Some(&shas[0]),
3985 "2026-07-29T00:00:00Z",
3986 )
3987 .await;
3988
3989 let rows = pom::versions::collect(&pool, &config).await.unwrap();
3990 assert_eq!(rows[0].commits_behind, Some(2), "row: {:?}", rows[0]);
3991 assert!(rows[0].behind_error.is_none());
3992
3993 // Live sha == HEAD is zero behind, not a missing measurement.
3994 insert_version_health(
3995 &pool,
3996 "mnw",
3997 Some("0.11.1"),
3998 Some(&shas[2]),
3999 "2026-07-29T01:00:00Z",
4000 )
4001 .await;
4002 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4003 assert_eq!(rows[0].commits_behind, Some(0));
4004
4005 std::fs::remove_dir_all(&path).unwrap();
4006 }
4007
4008 #[tokio::test]
4009 async fn versions_scopes_the_count_to_the_configured_subdir() {
4010 // Every commit here touches server/, then one lands outside it. Scoped to
4011 // server/, the trailing commit must not count against the deployed build.
4012 let (path, shas) = scratch_repo("subdir", 2, Some("server"));
4013 std::fs::write(path.join("unrelated"), "x\n").unwrap();
4014 let git = |args: &[&str]| {
4015 let out = std::process::Command::new("git")
4016 .arg("-C")
4017 .arg(&path)
4018 .args(args)
4019 .output()
4020 .unwrap();
4021 assert!(
4022 out.status.success(),
4023 "{}",
4024 String::from_utf8_lossy(&out.stderr)
4025 );
4026 };
4027 git(&["add", "-A"]);
4028 git(&["commit", "-m", "outside server"]);
4029
4030 let pool = db::connect_in_memory().await.unwrap();
4031 let config: pom::config::Config = toml::from_str(&format!(
4032 r#"
4033 [targets.mnw]
4034 label = "MakeNotWork"
4035 [targets.mnw.repo]
4036 path = "{}"
4037 subdir = "server"
4038 "#,
4039 path.display()
4040 ))
4041 .unwrap();
4042
4043 insert_version_health(
4044 &pool,
4045 "mnw",
4046 Some("0.11.0"),
4047 Some(&shas[0]),
4048 "2026-07-29T00:00:00Z",
4049 )
4050 .await;
4051
4052 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4053 assert_eq!(rows[0].commits_behind, Some(1), "row: {:?}", rows[0]);
4054
4055 std::fs::remove_dir_all(&path).unwrap();
4056 }
4057
4058 #[tokio::test]
4059 async fn versions_blanks_the_column_when_the_repo_is_not_reachable() {
4060 let pool = db::connect_in_memory().await.unwrap();
4061 let config: pom::config::Config = toml::from_str(
4062 r#"
4063 [targets.mnw]
4064 label = "MakeNotWork"
4065 [targets.mnw.repo]
4066 path = "/nonexistent/pom-versions-test"
4067 "#,
4068 )
4069 .unwrap();
4070
4071 insert_version_health(
4072 &pool,
4073 "mnw",
4074 Some("0.11.0"),
4075 Some("6402bf4e"),
4076 "2026-07-29T00:00:00Z",
4077 )
4078 .await;
4079
4080 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4081 assert!(rows[0].commits_behind.is_none());
4082 assert!(
4083 rows[0].behind_error.is_some(),
4084 "a failed count must say why"
4085 );
4086 // The rest of the row still reports.
4087 assert_eq!(rows[0].version.as_deref(), Some("0.11.0"));
4088 }
4089
4090 #[tokio::test]
4091 async fn versions_stays_quiet_about_a_target_that_has_never_been_checked() {
4092 // A repo is configured, but no health check has ever run. There is nothing
4093 // to report yet, and claiming the target "reports no git_sha" would blame
4094 // it for a check that never happened.
4095 let (path, _) = scratch_repo("nocheck", 1, None);
4096 let pool = db::connect_in_memory().await.unwrap();
4097 let config: pom::config::Config = toml::from_str(&format!(
4098 r#"
4099 [targets.mnw]
4100 label = "MakeNotWork"
4101 [targets.mnw.repo]
4102 path = "{}"
4103 "#,
4104 path.display()
4105 ))
4106 .unwrap();
4107
4108 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4109 assert!(rows[0].checked_at.is_none());
4110 assert!(rows[0].commits_behind.is_none());
4111 assert!(rows[0].behind_error.is_none(), "{:?}", rows[0].behind_error);
4112
4113 std::fs::remove_dir_all(&path).unwrap();
4114 }
4115
4116 #[tokio::test]
4117 async fn versions_says_when_a_target_reports_no_sha_to_anchor_on() {
4118 let (path, _) = scratch_repo("nosha", 1, None);
4119 let pool = db::connect_in_memory().await.unwrap();
4120 let config: pom::config::Config = toml::from_str(&format!(
4121 r#"
4122 [targets.mnw]
4123 label = "MakeNotWork"
4124 [targets.mnw.repo]
4125 path = "{}"
4126 "#,
4127 path.display()
4128 ))
4129 .unwrap();
4130
4131 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T00:00:00Z").await;
4132
4133 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4134 assert!(rows[0].commits_behind.is_none());
4135 assert!(rows[0].behind_error.as_deref().unwrap().contains("git_sha"));
4136
4137 std::fs::remove_dir_all(&path).unwrap();
4138 }
4139
4140 #[tokio::test]
4141 async fn versions_rejects_a_git_sha_that_git_would_read_as_an_option() {
4142 let (path, _) = scratch_repo("injection", 1, None);
4143 let pool = db::connect_in_memory().await.unwrap();
4144 let config: pom::config::Config = toml::from_str(&format!(
4145 r#"
4146 [targets.mnw]
4147 label = "MakeNotWork"
4148 [targets.mnw.repo]
4149 path = "{}"
4150 "#,
4151 path.display()
4152 ))
4153 .unwrap();
4154
4155 insert_version_health(
4156 &pool,
4157 "mnw",
4158 Some("0.11.0"),
4159 Some("--output=/tmp/pom-versions-pwned"),
4160 "2026-07-29T00:00:00Z",
4161 )
4162 .await;
4163
4164 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4165 assert!(rows[0].commits_behind.is_none());
4166 assert!(
4167 rows[0]
4168 .behind_error
4169 .as_deref()
4170 .unwrap()
4171 .contains("unusable")
4172 );
4173 assert!(!std::path::Path::new("/tmp/pom-versions-pwned").exists());
4174
4175 std::fs::remove_dir_all(&path).unwrap();
4176 }
4177
4178 #[test]
4179 fn health_details_written_before_git_sha_existed_still_read() {
4180 // Rows already in health_checks have no git_sha key. They must come back as
4181 // snapshots with a blank sha, not fail to deserialize and lose the version.
4182 let old = r#"{"version":"0.10.0","uptime":"3d","checks":null,"monitoring":null}"#;
4183 let details: HealthDetails = serde_json::from_str(old).unwrap();
4184 assert_eq!(details.version.as_deref(), Some("0.10.0"));
4185 assert!(details.git_sha.is_none());
4186 }
4187
4188 #[test]
4189 fn repo_path_must_be_absolute() {
4190 let toml = r#"
4191 [targets.mnw]
4192 label = "MakeNotWork"
4193 [targets.mnw.repo]
4194 path = "../server"
4195 "#;
4196 let tmp = std::env::temp_dir().join(format!("pom_repo_rel_{}.toml", std::process::id()));
4197 std::fs::write(&tmp, toml).unwrap();
4198 let result = pom::config::Config::load(Some(tmp.as_path()));
4199 std::fs::remove_file(&tmp).unwrap();
4200 let err = result.unwrap_err().to_string();
4201 assert!(err.contains("must be absolute"), "error: {err}");
4202 }
4203
4204 // Read-only orientation tools
4205
4206 /// A server whose config also knows one peer, so instance resolution has
4207 /// something to resolve and something to reject.
4208 fn orient_server(pool: sqlx::SqlitePool) -> PomServer {
4209 let config: pom::config::Config = toml::from_str(
4210 r#"
4211 [targets.mnw]
4212 label = "MakeNotWork"
4213 [targets.mnw.health]
4214 url = "https://makenot.work/api/health"
4215
4216 [peers.hetzner]
4217 address = "100.64.0.1:9100"
4218 token = "peer-token"
4219 "#,
4220 )
4221 .unwrap();
4222 PomServer::new(pool, config)
4223 }
4224
4225 fn instance_params(instance: Option<&str>) -> pom::tools::orient::InstanceParams {
4226 serde_json::from_value(serde_json::json!({ "instance": instance })).unwrap()
4227 }
4228
4229 #[tokio::test]
4230 async fn tool_status_table_reports_one_line_per_target() {
4231 let pool = db::connect_in_memory().await.unwrap();
4232 let server = orient_server(pool.clone());
4233 insert_version_health(
4234 &pool,
4235 "mnw",
4236 Some("0.11.0"),
4237 Some("6402bf4e"),
4238 "2026-07-29T18:00:00Z",
4239 )
4240 .await;
4241
4242 let out = server
4243 .status_table_impl(instance_params(None))
4244 .await
4245 .unwrap();
4246 assert!(out.contains("TARGET"), "{out}");
4247 let row = out
4248 .lines()
4249 .find(|l| l.starts_with("mnw"))
4250 .unwrap_or_else(|| panic!("no mnw row in:\n{out}"));
4251 // "ok" is the shared status vocabulary of the payload, not PoM's own
4252 // "operational": the table speaks the contract every source speaks.
4253 assert!(row.contains("ok"), "row: {row}");
4254 assert!(row.contains("0.11.0"), "row: {row}");
4255 }
4256
4257 #[tokio::test]
4258 async fn tool_status_table_puts_the_worst_target_first() {
4259 let pool = db::connect_in_memory().await.unwrap();
4260 let config: pom::config::Config = toml::from_str(
4261 r#"
4262 [targets.aaa]
4263 label = "Fine"
4264 [targets.aaa.health]
4265 url = "https://example.invalid/health"
4266 [targets.zzz]
4267 label = "Broken"
4268 [targets.zzz.health]
4269 url = "https://example.invalid/health"
4270 "#,
4271 )
4272 .unwrap();
4273 let server = PomServer::new(pool.clone(), config);
4274
4275 insert_version_health(&pool, "aaa", Some("1.0.0"), None, "2026-07-29T18:00:00Z").await;
4276 let broken = HealthSnapshot {
4277 id: None,
4278 target: "zzz".to_string(),
4279 status: HealthStatus::Unreachable,
4280 checked_at: "2026-07-29T18:00:00Z".to_string(),
4281 response_time_ms: 0,
4282 details: None,
4283 error: Some("connection refused".to_string()),
4284 };
4285 db::insert_health_check(&pool, &broken).await.unwrap();
4286
4287 let out = server
4288 .status_table_impl(instance_params(None))
4289 .await
4290 .unwrap();
4291 let first_target = out
4292 .lines()
4293 .find(|l| l.starts_with("aaa") || l.starts_with("zzz"))
4294 .unwrap();
4295 assert!(
4296 first_target.starts_with("zzz"),
4297 "the broken target must head the table:\n{out}"
4298 );
4299 assert!(first_target.contains("connection refused"), "{out}");
4300 }
4301
4302 #[tokio::test]
4303 async fn tool_target_status_lists_every_condition() {
4304 let pool = db::connect_in_memory().await.unwrap();
4305 let server = orient_server(pool.clone());
4306 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T18:00:00Z").await;
4307 db::insert_incident(&pool, "mnw", "operational", "degraded")
4308 .await
4309 .unwrap();
4310
4311 let params: pom::tools::orient::TargetInstanceParams =
4312 serde_json::from_value(serde_json::json!({ "target": "mnw" })).unwrap();
4313 let out = server.target_status_impl(params).await.unwrap();
4314 assert!(out.contains("health"), "{out}");
4315 assert!(out.contains("incident"), "{out}");
4316 assert!(out.contains("version: 0.11.0"), "{out}");
4317 }
4318
4319 #[tokio::test]
4320 async fn tool_target_status_names_the_known_targets_when_asked_for_a_stranger() {
4321 let pool = db::connect_in_memory().await.unwrap();
4322 let server = orient_server(pool);
4323
4324 let params: pom::tools::orient::TargetInstanceParams =
4325 serde_json::from_value(serde_json::json!({ "target": "nope" })).unwrap();
4326 let out = server.target_status_impl(params).await.unwrap();
4327 assert!(out.contains("Unknown target"), "{out}");
4328 assert!(out.contains("mnw"), "must list what it does know: {out}");
4329 }
4330
4331 #[tokio::test]
4332 async fn tool_incidents_reports_open_ones_and_other_failing_checks() {
4333 let pool = db::connect_in_memory().await.unwrap();
4334 let server = orient_server(pool.clone());
4335
4336 let out = server.incidents_impl(instance_params(None)).await.unwrap();
4337 assert!(out.contains("No open incidents"), "{out}");
4338
4339 db::insert_incident(&pool, "mnw", "operational", "error")
4340 .await
4341 .unwrap();
4342 let out = server.incidents_impl(instance_params(None)).await.unwrap();
4343 assert!(out.contains("mnw / incident"), "{out}");
4344 // The health condition is pending (no check recorded), which is not a
4345 // failure and must not be reported as one.
4346 assert!(!out.contains("mnw / health"), "{out}");
4347 }
4348
4349 #[tokio::test]
4350 async fn tool_versions_returns_the_roll_up() {
4351 let pool = db::connect_in_memory().await.unwrap();
4352 let server = orient_server(pool.clone());
4353 insert_version_health(
4354 &pool,
4355 "mnw",
4356 Some("0.11.0"),
4357 Some("6402bf4e"),
4358 "2026-07-29T18:00:00Z",
4359 )
4360 .await;
4361
4362 let out = server.versions_impl(instance_params(None)).await.unwrap();
4363 assert!(out.contains("TARGET"), "{out}");
4364 assert!(out.contains("0.11.0") && out.contains("6402bf4e"), "{out}");
4365 }
4366
4367 #[tokio::test]
4368 async fn tool_trends_reports_the_window_and_baseline() {
4369 let pool = db::connect_in_memory().await.unwrap();
4370 let server = orient_server(pool.clone());
4371 for i in 0..3 {
4372 let snapshot = HealthSnapshot {
4373 id: None,
4374 target: "mnw".to_string(),
4375 status: HealthStatus::Operational,
4376 checked_at: chrono::Utc::now().to_rfc3339(),
4377 response_time_ms: 100 + i,
4378 details: None,
4379 error: None,
4380 };
4381 db::insert_health_check(&pool, &snapshot).await.unwrap();
4382 }
4383
4384 let params: pom::tools::orient::TrendsParams =
4385 serde_json::from_value(serde_json::json!({ "target": "mnw" })).unwrap();
4386 let out = server.trends_impl(params).await.unwrap();
4387 assert!(out.contains("last 24h"), "{out}");
4388 assert!(out.contains("Window: avg"), "{out}");
4389
4390 let unknown: pom::tools::orient::TrendsParams =
4391 serde_json::from_value(serde_json::json!({ "target": "nope" })).unwrap();
4392 let out = server.trends_impl(unknown).await.unwrap();
4393 assert!(out.contains("Unknown target"), "{out}");
4394 }
4395
4396 #[tokio::test]
4397 async fn tool_unknown_instance_names_the_configured_peers() {
4398 let pool = db::connect_in_memory().await.unwrap();
4399 let server = orient_server(pool);
4400
4401 let err = server
4402 .status_table_impl(instance_params(Some("mars")))
4403 .await
4404 .unwrap_err()
4405 .to_string();
4406 assert!(err.contains("unknown instance"), "{err}");
4407 assert!(
4408 err.contains("hetzner"),
4409 "must list the peers it knows: {err}"
4410 );
4411 }
4412
4413 #[tokio::test]
4414 async fn tool_local_instance_needs_no_running_daemon() {
4415 // The whole point of reading the pool directly: `pom serve` is not up in
4416 // this test, and the local answer still comes back.
4417 let pool = db::connect_in_memory().await.unwrap();
4418 let server = orient_server(pool.clone());
4419 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T18:00:00Z").await;
4420
4421 for instance in [None, Some("local")] {
4422 let out = server
4423 .status_table_impl(instance_params(instance))
4424 .await
4425 .unwrap();
4426 assert!(out.contains("mnw"), "{out}");
4427 }
4428 }
4429
4430 #[tokio::test]
4431 async fn api_versions_serves_the_roll_up() {
4432 let pool = db::connect_in_memory().await.unwrap();
4433 let app = pom::api::router(pool.clone(), test_config(), None);
4434 insert_version_health(
4435 &pool,
4436 "mnw",
4437 Some("0.11.0"),
4438 Some("6402bf4e"),
4439 "2026-07-29T18:00:00Z",
4440 )
4441 .await;
4442
4443 let (status, json) = api_get(&app, "/api/versions").await;
4444 assert_eq!(status, 200);
4445 assert_eq!(json[0]["target"], "mnw");
4446 assert_eq!(json[0]["version"], "0.11.0");
4447 assert_eq!(json[0]["git_sha"], "6402bf4e");
4448 }
4449
4450 #[tokio::test]
4451 async fn versions_reports_when_the_live_version_was_first_seen() {
4452 let pool = db::connect_in_memory().await.unwrap();
4453 let config: pom::config::Config = toml::from_str(
4454 r#"
4455 [targets.mnw]
4456 label = "MakeNotWork"
4457 "#,
4458 )
4459 .unwrap();
4460
4461 insert_version_health(&pool, "mnw", Some("0.10.0"), None, "2026-07-20T00:00:00Z").await;
4462 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-27T00:00:00Z").await;
4463 // An unreachable check reports no version at all. It must not read as a
4464 // version change, or every blip would look like a fresh deploy.
4465 let blip = HealthSnapshot {
4466 id: None,
4467 target: "mnw".to_string(),
4468 status: HealthStatus::Unreachable,
4469 checked_at: "2026-07-28T00:00:00Z".to_string(),
4470 response_time_ms: 0,
4471 details: None,
4472 error: Some("timeout".to_string()),
4473 };
4474 db::insert_health_check(&pool, &blip).await.unwrap();
4475 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T00:00:00Z").await;
4476
4477 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4478 assert_eq!(rows[0].version.as_deref(), Some("0.11.0"));
4479 assert_eq!(
4480 rows[0].version_since.as_deref(),
4481 Some("2026-07-27T00:00:00Z")
4482 );
4483 assert_eq!(rows[0].checked_at.as_deref(), Some("2026-07-29T00:00:00Z"));
4484 }
4485
4486 #[tokio::test]
4487 async fn versions_first_seen_moves_when_the_version_does() {
4488 let pool = db::connect_in_memory().await.unwrap();
4489 let config: pom::config::Config = toml::from_str(
4490 r#"
4491 [targets.mnw]
4492 label = "MakeNotWork"
4493 "#,
4494 )
4495 .unwrap();
4496
4497 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-27T00:00:00Z").await;
4498 let before = pom::versions::collect(&pool, &config).await.unwrap();
4499 assert_eq!(
4500 before[0].version_since.as_deref(),
4501 Some("2026-07-27T00:00:00Z")
4502 );
4503
4504 insert_version_health(&pool, "mnw", Some("0.11.1"), None, "2026-07-29T12:00:00Z").await;
4505 let after = pom::versions::collect(&pool, &config).await.unwrap();
4506 assert_eq!(after[0].version.as_deref(), Some("0.11.1"));
4507 assert_eq!(
4508 after[0].version_since.as_deref(),
4509 Some("2026-07-29T12:00:00Z")
4510 );
4511 }
4512