diff --git a/crates/database/src/servers.rs b/crates/database/src/servers.rs index 0940d04c..ac7c6b5f 100644 --- a/crates/database/src/servers.rs +++ b/crates/database/src/servers.rs @@ -1,5 +1,6 @@ use commons_errors::{AppError, Result}; use commons_types::{ + device::DeviceRole, geo::GeoPoint, server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket}, }; @@ -223,12 +224,18 @@ impl Server { _ => {} } - // Find or create the device that owns this key. + // Find or create the device that owns this key. The ticket is + // the operator's trust signal — promote a freshly-created (or + // previously-Untrusted) device to `Server` so they don't have + // to flip it manually after import. let device = if let Some(device) = crate::devices::Device::from_key(db, &key_der).await? { device } else { crate::devices::Device::create(db, key_der).await? }; + if device.role == DeviceRole::Untrusted { + crate::devices::Device::trust(db, device.id, DeviceRole::Server).await?; + } let cloud = ticket.hosting.as_deref().map(|h| { matches!( @@ -252,23 +259,34 @@ impl Server { }; let kind_str = server_value.kind.to_string(); + let rank_str = server_value.rank.map(|r| r.to_string()); - // Upsert: insert or update on conflict. + // Upsert: insert or update on conflict. Ticket-derived fields + // (kind, rank, cloud, parent_server_id) re-apply on update; + // operator-edited state (listed, geolocation, alert_when_down) + // is preserved. diesel::insert_into(servers::table) .values(( servers::id.eq(server_value.id), servers::name.eq(&server_value.name), servers::host.eq(&host_str), servers::kind.eq(&kind_str), + servers::rank.eq(&rank_str), servers::device_id.eq(server_value.device_id), + servers::parent_server_id.eq(server_value.parent_server_id), servers::listed.eq(server_value.listed), + servers::cloud.eq(server_value.cloud), )) .on_conflict(servers::id) .do_update() .set(( servers::name.eq(&server_value.name), servers::host.eq(&host_str), + servers::kind.eq(&kind_str), + servers::rank.eq(&rank_str), servers::device_id.eq(server_value.device_id), + servers::parent_server_id.eq(server_value.parent_server_id), + servers::cloud.eq(server_value.cloud), )) .returning(Self::as_select()) .get_result(db) diff --git a/crates/database/tests/upsert_from_ticket.rs b/crates/database/tests/upsert_from_ticket.rs index 657391c3..02516bcb 100644 --- a/crates/database/tests/upsert_from_ticket.rs +++ b/crates/database/tests/upsert_from_ticket.rs @@ -1,5 +1,8 @@ -use commons_types::server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket}; -use database::servers::Server; +use commons_types::{ + device::DeviceRole, + server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket}, +}; +use database::{devices::Device, servers::Server}; use uuid::Uuid; fn synthesize_ticket(server_id: Uuid, hostname: &str, url: &str) -> CanopyTicket { @@ -41,6 +44,98 @@ async fn upsert_from_ticket_smoke() { .await } +#[tokio::test(flavor = "multi_thread")] +async fn upsert_from_ticket_persists_rank_and_trusts_device() { + commons_tests::db::TestDb::run(async |mut conn, _| { + let id = Uuid::new_v4(); + let ticket = synthesize_ticket(id, "alpha", "https://alpha.example.com"); + let server = Server::upsert_from_ticket( + &mut conn, + &ticket, + ServerKind::Facility, + Some(ServerRank::Production), + ) + .await + .expect("upsert"); + + assert_eq!(server.rank, Some(ServerRank::Production)); + assert_eq!(server.kind, ServerKind::Facility); + + let device_id = server.device_id.expect("server has a device"); + let device = Device::get_with_info(&mut conn, device_id).await.expect("device").device; + assert_eq!(device.role, DeviceRole::Server); + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn upsert_from_ticket_re_import_refreshes_rank() { + commons_tests::db::TestDb::run(async |mut conn, _| { + let id = Uuid::new_v4(); + let ticket = synthesize_ticket(id, "alpha", "https://alpha.example.com"); + + let first = Server::upsert_from_ticket( + &mut conn, + &ticket, + ServerKind::Facility, + Some(ServerRank::Demo), + ) + .await + .expect("first upsert"); + assert_eq!(first.rank, Some(ServerRank::Demo)); + + let second = Server::upsert_from_ticket( + &mut conn, + &ticket, + ServerKind::Facility, + Some(ServerRank::Production), + ) + .await + .expect("second upsert"); + assert_eq!(second.rank, Some(ServerRank::Production)); + assert_eq!(second.id, first.id); + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn upsert_from_ticket_preserves_higher_role_on_existing_device() { + commons_tests::db::TestDb::run(async |mut conn, _| { + let id = Uuid::new_v4(); + let ticket = synthesize_ticket(id, "alpha", "https://alpha.example.com"); + + // First import → device gets Server role. + let first = Server::upsert_from_ticket( + &mut conn, + &ticket, + ServerKind::Facility, + Some(ServerRank::Production), + ) + .await + .expect("first upsert"); + let device_id = first.device_id.expect("device_id"); + + // Manually promote to Admin (simulates an operator who chose + // to give this device extra privileges). + Device::trust(&mut conn, device_id, DeviceRole::Admin) + .await + .expect("trust admin"); + + // Re-import the same ticket. The device should *not* be demoted. + let _ = Server::upsert_from_ticket( + &mut conn, + &ticket, + ServerKind::Facility, + Some(ServerRank::Production), + ) + .await + .expect("second upsert"); + let device = Device::get_with_info(&mut conn, device_id).await.expect("device").device; + assert_eq!(device.role, DeviceRole::Admin); + }) + .await +} + #[tokio::test(flavor = "multi_thread")] async fn upsert_from_ticket_idempotent() { commons_tests::db::TestDb::run(async |mut conn, _| {