Skip to main content

max / makenotwork

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