diff --git a/backend/src/db/push_tokens.rs b/backend/src/db/push_tokens.rs index 58d80c8..f2bed43 100644 --- a/backend/src/db/push_tokens.rs +++ b/backend/src/db/push_tokens.rs @@ -124,7 +124,11 @@ mod tests { let after_update = list().unwrap(); let rows: Vec<_> = after_update.iter().filter(|t| t.token == token).collect(); assert_eq!(rows.len(), 1, "Re-registering must not create a second row"); - assert_eq!(rows[0].platform, PushPlatform::Ios, "Platform should update"); + assert_eq!( + rows[0].platform, + PushPlatform::Ios, + "Platform should update" + ); assert_eq!( rows[0].created_on, created_on, "created_on must be preserved across an upsert" diff --git a/backend/src/main.rs b/backend/src/main.rs index fb1adec..765edac 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,7 @@ use crate::settings::get_settings; use clap::Parser; -use log::{LevelFilter, info}; +use log::{LevelFilter, error, info}; +use std::future::Future; mod data; mod db; @@ -66,15 +67,31 @@ async fn main() -> Result<(), Box> { // Start the device scanners, web server, retention cleaner, and notification delivery loop in // parallel. Notification delivery runs on its own task so a slow Pushover never stalls a scan. + // Each task is wrapped so that if it exits with an error (e.g. the DHCP scanner failing to bind + // its socket because the port is already in use) the failure is logged rather than silently + // swallowed — otherwise a scanner just appears "off" with no explanation. tokio::join!( - scanners::arp::scanner::scan(), - scanners::mdns::scanner::listen(), - scanners::ssdp::scanner::listen(), - scanners::dhcp::scanner::listen(), - scanners::snmp::scanner::scan(), - web_server::serve(), + log_task_errors("ARP scanner", scanners::arp::scanner::scan()), + log_task_errors("mDNS scanner", scanners::mdns::scanner::listen()), + log_task_errors("SSDP scanner", scanners::ssdp::scanner::listen()), + log_task_errors("DHCP scanner", scanners::dhcp::scanner::listen()), + log_task_errors("SNMP scanner", scanners::snmp::scanner::scan()), + log_task_errors("web server", web_server::serve()), retention::run(), - notifications::run_delivery() - ) - .0 + notifications::run_delivery(), + ); + + Ok(()) +} + +/// Await a long-running task and log its error if it exits with one. Without this the errors of +/// every task but the first were dropped by `tokio::join!`, so a scanner that failed to start +/// (for example the DHCP scanner being unable to bind port 67) reported no diagnostic at all. +async fn log_task_errors(name: &str, task: F) +where + F: Future>>, +{ + if let Err(err) = task.await { + error!("{name} exited with error: {err}"); + } } diff --git a/backend/src/model/push_tokens.rs b/backend/src/model/push_tokens.rs index bc851ab..4dcc2f9 100644 --- a/backend/src/model/push_tokens.rs +++ b/backend/src/model/push_tokens.rs @@ -107,7 +107,10 @@ mod tests { #[test] fn platform_parse_is_case_insensitive() { - assert_eq!("ANDROID".parse::().unwrap(), PushPlatform::Android); + assert_eq!( + "ANDROID".parse::().unwrap(), + PushPlatform::Android + ); assert_eq!("iOS".parse::().unwrap(), PushPlatform::Ios); } diff --git a/backend/src/notifications/delivery.rs b/backend/src/notifications/delivery.rs index 3c524e0..a6d5a4f 100644 --- a/backend/src/notifications/delivery.rs +++ b/backend/src/notifications/delivery.rs @@ -62,7 +62,11 @@ async fn deliver(request: DeliveryRequest) { // The relay URL defaults to the project-operated relay, so the [notifications.push] // section is optional. The send call is async (reqwest), so unlike Pushover it is // awaited directly rather than dispatched to the blocking pool. - let config = get_settings().notifications.push.clone().unwrap_or_default(); + let config = get_settings() + .notifications + .push + .clone() + .unwrap_or_default(); if let Err(err) = push::send(&config, request.title, request.body).await { error!("Failed to deliver notification via the push relay: {err}"); } diff --git a/backend/src/notifications/push.rs b/backend/src/notifications/push.rs index 6c5871c..9a82037 100644 --- a/backend/src/notifications/push.rs +++ b/backend/src/notifications/push.rs @@ -77,7 +77,10 @@ pub async fn send(config: &Push, title: String, body: String) -> Result::openapi(); assert_eq!(openapi.info.version, env!("CARGO_PKG_VERSION")); } + + #[test] + fn openapi_operation_ids_are_unique() { + // Every operation defaults its operationId to its handler function name, so handlers that + // share a name (e.g. each scanner's `status`, or `read`/`list` across resources) collide. + // Duplicate operationIds are invalid OpenAPI and make Swagger UI's "Try it out" execute the + // first operation with that id (so the DHCP doc hit `/api/arp_scanner/status`). Assert they + // are all unique so that regression cannot return. + let openapi = ::openapi(); + let mut seen = std::collections::HashSet::new(); + for (path, item) in &openapi.paths.paths { + let operations = [ + &item.get, + &item.put, + &item.post, + &item.delete, + &item.options, + &item.head, + &item.patch, + &item.trace, + ]; + for operation in operations.into_iter().flatten() { + let id = operation + .operation_id + .as_ref() + .unwrap_or_else(|| panic!("{path} has an operation with no operationId")); + assert!( + seen.insert(id.clone()), + "duplicate operationId {id:?} (at {path})" + ); + } + } + } } diff --git a/backend/src/web_server/arp_scanner.rs b/backend/src/web_server/arp_scanner.rs index c68e68f..06d56c8 100644 --- a/backend/src/web_server/arp_scanner.rs +++ b/backend/src/web_server/arp_scanner.rs @@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_resp #[utoipa::path( get, path = "/api/arp_scanner/status", + operation_id = "arp_scanner_status", tag = "arp_scanner", responses( (status = 200, description = "ARP scanner status", body = ActiveScannerStatusResponse), diff --git a/backend/src/web_server/config.rs b/backend/src/web_server/config.rs index 8cb0711..2dc5425 100644 --- a/backend/src/web_server/config.rs +++ b/backend/src/web_server/config.rs @@ -6,6 +6,7 @@ use crate::settings::get_settings; #[utoipa::path( get, path = "/api/config", + operation_id = "config_read", tag = "config", responses( (status = 200, description = "Front-end configuration", body = Config), diff --git a/backend/src/web_server/device_events.rs b/backend/src/web_server/device_events.rs index 8b5a4a0..71e4cfe 100644 --- a/backend/src/web_server/device_events.rs +++ b/backend/src/web_server/device_events.rs @@ -12,6 +12,7 @@ use crate::{db, model::device_events::DeviceEvent, web_server::utils}; #[utoipa::path( get, path = "/api/devices/{mac_address}/events", + operation_id = "device_events_list", tag = "device_events", params( ("mac_address" = String, Path, description = "MAC address of the device"), diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index edc527a..dedfbd9 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -18,6 +18,7 @@ use crate::web_server::utils; #[utoipa::path( get, path = "/api/devices", + operation_id = "devices_list", tag = "devices", params( ("is_registered" = Option, Query, description = "Filter by registration status"), @@ -95,6 +96,7 @@ pub async fn list( #[utoipa::path( get, path = "/api/devices/{mac_address}", + operation_id = "devices_read", tag = "devices", params( ("mac_address" = String, Path, description = "MAC address of the device"), @@ -116,6 +118,7 @@ pub async fn read(Path(mac_address): Path) -> Result, Statu #[utoipa::path( put, path = "/api/devices", + operation_id = "devices_register", tag = "devices", request_body = RegisterDevicePayload, responses( @@ -235,6 +238,7 @@ pub async fn update( #[utoipa::path( delete, path = "/api/devices/{mac_address}", + operation_id = "devices_unregister", tag = "devices", params( ("mac_address" = String, Path, description = "MAC address of the device"), diff --git a/backend/src/web_server/dhcp_scanner.rs b/backend/src/web_server/dhcp_scanner.rs index 7be317a..35de1e5 100644 --- a/backend/src/web_server/dhcp_scanner.rs +++ b/backend/src/web_server/dhcp_scanner.rs @@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_re #[utoipa::path( get, path = "/api/dhcp_scanner/status", + operation_id = "dhcp_scanner_status", tag = "dhcp_scanner", responses( (status = 200, description = "DHCP scanner status", body = PassiveScannerStatusResponse), diff --git a/backend/src/web_server/mdns_scanner.rs b/backend/src/web_server/mdns_scanner.rs index da5c27e..cd0ec32 100644 --- a/backend/src/web_server/mdns_scanner.rs +++ b/backend/src/web_server/mdns_scanner.rs @@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_re #[utoipa::path( get, path = "/api/mdns_scanner/status", + operation_id = "mdns_scanner_status", tag = "mdns_scanner", responses( (status = 200, description = "mDNS scanner status", body = PassiveScannerStatusResponse), diff --git a/backend/src/web_server/notifications.rs b/backend/src/web_server/notifications.rs index 33e706f..ddb6582 100644 --- a/backend/src/web_server/notifications.rs +++ b/backend/src/web_server/notifications.rs @@ -26,6 +26,7 @@ pub struct TestNotificationResponse { #[utoipa::path( get, path = "/api/notifications/{id}", + operation_id = "notifications_read", tag = "notifications", params( ("id" = i64, Path, description = "Notification ID"), @@ -150,6 +151,7 @@ pub async fn send_test() -> Result, StatusCode> { #[utoipa::path( get, path = "/api/notifications", + operation_id = "notifications_list", tag = "notifications", params( ("is_new" = Option, Query, description = "Filter by new/read status"), diff --git a/backend/src/web_server/push_tokens.rs b/backend/src/web_server/push_tokens.rs index 63ab72a..9e98448 100644 --- a/backend/src/web_server/push_tokens.rs +++ b/backend/src/web_server/push_tokens.rs @@ -11,6 +11,7 @@ use crate::model::push_tokens::PushPlatform; #[utoipa::path( put, path = "/api/push_tokens", + operation_id = "push_tokens_register", tag = "push_tokens", request_body = RegisterPushTokenPayload, responses( @@ -38,6 +39,7 @@ pub async fn register(Json(payload): Json) -> impl Int #[utoipa::path( delete, path = "/api/push_tokens/{token}", + operation_id = "push_tokens_unregister", tag = "push_tokens", params( ("token" = String, Path, description = "The push token to unregister"), diff --git a/backend/src/web_server/snmp_scanner.rs b/backend/src/web_server/snmp_scanner.rs index 50e8c78..18e98ff 100644 --- a/backend/src/web_server/snmp_scanner.rs +++ b/backend/src/web_server/snmp_scanner.rs @@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_resp #[utoipa::path( get, path = "/api/snmp_scanner/status", + operation_id = "snmp_scanner_status", tag = "snmp_scanner", responses( (status = 200, description = "SNMP scanner status", body = ActiveScannerStatusResponse), diff --git a/backend/src/web_server/ssdp_scanner.rs b/backend/src/web_server/ssdp_scanner.rs index cdf413c..8a910d7 100644 --- a/backend/src/web_server/ssdp_scanner.rs +++ b/backend/src/web_server/ssdp_scanner.rs @@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_re #[utoipa::path( get, path = "/api/ssdp_scanner/status", + operation_id = "ssdp_scanner_status", tag = "ssdp_scanner", responses( (status = 200, description = "SSDP scanner status", body = PassiveScannerStatusResponse),