//! `GalaxyUserProfile` + async `UserResolver` trait. //! //! Direct port of `src/MxNativeClient/GalaxyRepositoryUserResolver.cs`. //! The .NET reference exposes a single concrete class with a SQL //! backend; the Rust port splits that into a trait + the data type + //! a separate role-blob parser ([`crate::role_blob::parse_role_blob`]) //! so consumers can plug in any backend (in-memory cache, JSON snapshot, //! REST client, planned `tiberius`-gated SQL impl). //! //! The user resolver is needed by F13's `WriteSecured*` flows — those //! pass `current_user_id` and `verifier_user_id` to identify who //! authorised a security-classified write. The user IDs are //! `dbo.user_profile.user_profile_id` (`int`), looked up either by //! `user_guid` (`uniqueidentifier`) or by `user_profile_name`. use crate::role_blob::parse_role_blob; use async_trait::async_trait; use thiserror::Error; use uuid::Uuid; /// Resolved user profile. Field order and types match the .NET /// `GalaxyUserProfile` record exactly (`GalaxyRepositoryUserResolver.cs:5-11`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct GalaxyUserProfile { pub user_profile_id: i32, pub user_profile_name: String, pub user_guid: Uuid, pub default_security_group: String, /// `None` when `dbo.user_profile.intouch_access_level IS NULL` /// (`cs:83`). pub intouch_access_level: Option, /// Role names parsed from the `roles` `varbinary` column via /// [`parse_role_blob`]. `Vec` (not `HashSet`) because the .NET /// reference returns an `IReadOnlyList` preserving discovery /// order; deduplication is case-insensitive (`cs:124`) and happens /// inside the parser. pub roles: Vec, } impl GalaxyUserProfile { /// Build a `GalaxyUserProfile` from raw column values, parsing the /// `roles_text` blob through [`parse_role_blob`]. Mirrors /// `ReadProfile` (`GalaxyRepositoryUserResolver.cs:76-85`). /// /// `roles_text = None` corresponds to `reader.IsDBNull(5)` at `cs:84` /// — yields an empty role list. #[must_use] pub fn from_columns( user_profile_id: i32, user_profile_name: String, user_guid: Uuid, default_security_group: String, intouch_access_level: Option, roles_text: Option<&str>, ) -> Self { Self { user_profile_id, user_profile_name, user_guid, default_security_group, intouch_access_level, roles: roles_text.map(parse_role_blob).unwrap_or_default(), } } } /// Errors raised by [`UserResolver`] implementations. Mirrors /// `KeyNotFoundException` at `cs:48,70` and the same `Backend` / /// pluggable-error split as [`crate::resolver::ResolverError`]. #[derive(Debug, Error)] #[non_exhaustive] pub enum UserResolverError { /// No user matched the supplied `user_guid` or `user_profile_name`. /// Mirrors the `KeyNotFoundException` at `cs:48` / `:70`. #[error("Galaxy user '{key}' was not found in dbo.user_profile")] NotFound { key: String }, /// Backend-specific failure (SQL connect / query error, etc.). #[error("Galaxy user resolver backend error: {message}")] Backend { message: String }, } /// Pluggable async user-profile resolver. /// /// Implementations should be thread-safe (`Send + Sync`) so a single /// resolver can be shared across the high-level write helpers in /// `mxaccess-nmx` and the M4 `Session` façade. #[async_trait] pub trait UserResolver: Send + Sync { /// Look up a user profile by GUID. Mirrors `ResolveByGuidAsync` /// (`GalaxyRepositoryUserResolver.cs:34-52`). /// /// # Errors /// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`]. async fn resolve_by_guid( &self, user_guid: Uuid, ) -> Result; /// Look up a user profile by name. Mirrors `ResolveByNameAsync` /// (`cs:54-74`). /// /// # Errors /// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`]. async fn resolve_by_name( &self, user_name: &str, ) -> Result; /// Convenience: look up the user profile id only. Mirrors /// `ResolveUserProfileIdByGuidAsync` (`cs:26-32`). Default impl /// delegates to [`Self::resolve_by_guid`]. /// /// # Errors /// As for [`Self::resolve_by_guid`]. async fn resolve_user_profile_id_by_guid( &self, user_guid: Uuid, ) -> Result { let profile = self.resolve_by_guid(user_guid).await?; Ok(profile.user_profile_id) } } #[cfg(test)] #[allow( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::panic )] mod tests { use super::*; use std::collections::HashMap; #[test] fn from_columns_handles_null_intouch_and_null_roles() { let p = GalaxyUserProfile::from_columns( 42, "TestUser".to_string(), Uuid::nil(), "Default".to_string(), None, None, ); assert_eq!(p.user_profile_id, 42); assert_eq!(p.user_profile_name, "TestUser"); assert_eq!(p.user_guid, Uuid::nil()); assert_eq!(p.default_security_group, "Default"); assert_eq!(p.intouch_access_level, None); assert!(p.roles.is_empty()); } #[test] fn from_columns_parses_roles_via_parse_role_blob() { // Encode "Owner" + 00 00 + tail terminator manually. let mut bytes: Vec = Vec::new(); for c in "Owner".chars() { let cu = c as u16; bytes.push((cu & 0xFF) as u8); bytes.push((cu >> 8) as u8); } bytes.extend_from_slice(&[0, 0, 0, 0]); let mut hex = String::from("0x"); for b in &bytes { hex.push_str(&format!("{b:02X}")); } let p = GalaxyUserProfile::from_columns( 7, "OwnerUser".to_string(), Uuid::nil(), "Default".to_string(), Some(9999), Some(&hex), ); assert_eq!(p.intouch_access_level, Some(9999)); assert_eq!(p.roles, vec!["Owner".to_string()]); } /// Tiny in-memory implementation for tests — proves the trait is /// implementable without any SQL machinery (mirrors the Resolver /// trait's InMemoryResolver test at resolver.rs). struct InMemoryUserResolver { by_guid: HashMap, by_name: HashMap, } impl InMemoryUserResolver { fn with_one(profile: GalaxyUserProfile) -> Self { let mut by_guid = HashMap::new(); by_guid.insert(profile.user_guid, profile.clone()); let mut by_name = HashMap::new(); by_name.insert(profile.user_profile_name.clone(), profile); Self { by_guid, by_name } } } #[async_trait] impl UserResolver for InMemoryUserResolver { async fn resolve_by_guid( &self, user_guid: Uuid, ) -> Result { self.by_guid .get(&user_guid) .cloned() .ok_or_else(|| UserResolverError::NotFound { key: user_guid.to_string(), }) } async fn resolve_by_name( &self, user_name: &str, ) -> Result { self.by_name .get(user_name) .cloned() .ok_or_else(|| UserResolverError::NotFound { key: user_name.to_string(), }) } } fn sample_profile() -> GalaxyUserProfile { GalaxyUserProfile::from_columns( 7, "TestUser".to_string(), Uuid::from_bytes([0xCC; 16]), "Default".to_string(), Some(9999), None, ) } #[tokio::test(flavor = "current_thread")] async fn in_memory_resolver_by_guid_round_trip() { let r = InMemoryUserResolver::with_one(sample_profile()); let p = r .resolve_by_guid(Uuid::from_bytes([0xCC; 16])) .await .unwrap(); assert_eq!(p.user_profile_id, 7); assert_eq!(p.user_profile_name, "TestUser"); } #[tokio::test(flavor = "current_thread")] async fn in_memory_resolver_by_name_round_trip() { let r = InMemoryUserResolver::with_one(sample_profile()); let p = r.resolve_by_name("TestUser").await.unwrap(); assert_eq!(p.user_profile_id, 7); } #[tokio::test(flavor = "current_thread")] async fn in_memory_resolver_not_found_by_guid() { let r = InMemoryUserResolver::with_one(sample_profile()); let err = r.resolve_by_guid(Uuid::nil()).await.unwrap_err(); assert!(matches!(err, UserResolverError::NotFound { .. })); } #[tokio::test(flavor = "current_thread")] async fn in_memory_resolver_not_found_by_name() { let r = InMemoryUserResolver::with_one(sample_profile()); let err = r.resolve_by_name("DoesNotExist").await.unwrap_err(); assert!(matches!(err, UserResolverError::NotFound { .. })); } #[tokio::test(flavor = "current_thread")] async fn resolve_user_profile_id_by_guid_default_impl_works() { let r = InMemoryUserResolver::with_one(sample_profile()); let id = r .resolve_user_profile_id_by_guid(Uuid::from_bytes([0xCC; 16])) .await .unwrap(); assert_eq!(id, 7); } }