diff --git a/amnet-server/src/card.rs b/amnet-server/src/card.rs index 17f9fad..f47a735 100644 --- a/amnet-server/src/card.rs +++ b/amnet-server/src/card.rs @@ -2,10 +2,8 @@ use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}}; use tracing::debug; -use crate::utils::{parse_access_code, parse_idm_hex}; - #[derive(Debug, Clone)] -pub (crate) struct StoredCard { +pub struct StoredCard { pub access_code: [u8; 10], pub card_idm: Option<[u8; 8]>, @@ -18,16 +16,14 @@ impl StoredCard { } } -pub (crate) enum CardPresentResult { +pub enum CardPresentResult { Accepted, - Rejected(String /* reason */), SlotUnavailable(Instant /* release time */), } static PRESENTER: OnceLock = OnceLock::new(); -#[derive(Debug)] -pub (crate) struct CardPresenter { +pub struct CardPresenter { current_card: Mutex>, } @@ -62,29 +58,16 @@ impl CardPresenter { card } - pub fn present_card(&self, access_code: &String, idm_hex: Option<&String>, validity: Duration) -> CardPresentResult { + pub fn present_card(&self, access_code: [u8; 10], idm_hex: Option<[u8; 8]>, validity: Duration) -> CardPresentResult { let mut guard = self.current_card.lock().unwrap(); if let Some(card) = guard.as_ref() && !card.is_expired() { return CardPresentResult::SlotUnavailable(card.expiry_time); } - let access_code_bytes = match parse_access_code(access_code) { - Some(bytes) => bytes, - None => return CardPresentResult::Rejected("Invalid access code format".to_string()), - }; - - let mut card_idm_bytes = None; - if let Some(idm_hex) = idm_hex { - match parse_idm_hex(idm_hex) { - Some(bytes) => card_idm_bytes = Some(bytes), - None => return CardPresentResult::Rejected("Invalid IDm format".to_string()), - } - } - let stored_card = StoredCard { - access_code: access_code_bytes, - card_idm: card_idm_bytes, + access_code, + card_idm: idm_hex, expiry_time: Instant::now() + validity, }; @@ -92,4 +75,197 @@ impl CardPresenter { CardPresentResult::Accepted } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::{parse_access_code, parse_idm_hex}; + + mod stored_card_tests { + use super::*; + + #[test] + fn is_expired_returns_false_for_future_expiry() { + let card = StoredCard { + access_code: [0; 10], + card_idm: None, + expiry_time: Instant::now() + Duration::from_secs(60), + }; + assert!(!card.is_expired()); + } + + #[test] + fn is_expired_returns_true_for_past_expiry() { + let card = StoredCard { + access_code: [0; 10], + card_idm: None, + expiry_time: Instant::now() - Duration::from_millis(1), + }; + assert!(card.is_expired()); + } + } + + mod card_presenter_tests { + use super::*; + + fn create_presenter() -> CardPresenter { + CardPresenter::default() + } + + #[test] + fn request_card_returns_none_when_empty() { + let presenter = create_presenter(); + assert!(presenter.request_card(None).is_none()); + } + + #[test] + fn present_card_accepts_valid_access_code() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + + match presenter.present_card(access_code, None, Duration::from_secs(5)) { + CardPresentResult::Accepted => {} + _ => panic!("Expected Accepted"), + } + } + + #[test] + fn present_card_rejects_invalid_access_code() { + let access_code = parse_access_code(&"INVALID".to_string()); + assert!(access_code.is_none(), "Expected parsing to fail for invalid access code"); + } + + #[test] + fn present_card_accepts_valid_idm() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap(); + + match presenter.present_card(access_code, Some(idm), Duration::from_secs(5)) { + CardPresentResult::Accepted => {} + _ => panic!("Expected Accepted"), + } + } + + #[test] + fn present_card_rejects_invalid_idm() { + let idm = parse_idm_hex(&"INVALID_IDM".to_string()); + assert!(idm.is_none(), "Expected parsing to fail for invalid IDm"); + } + + #[test] + fn present_card_returns_slot_unavailable_when_card_present() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + + // Present first card + presenter.present_card(access_code, None, Duration::from_secs(5)); + + // Try to present second card + match presenter.present_card(access_code, None, Duration::from_secs(5)) { + CardPresentResult::SlotUnavailable(_) => {} + _ => panic!("Expected SlotUnavailable"), + } + } + + #[test] + fn request_card_returns_card_after_present() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"00101234567890123456".to_string()).unwrap(); + + presenter.present_card(access_code, None, Duration::from_secs(5)); + + let card = presenter.request_card(None); + assert!(card.is_some()); + + let card = card.unwrap(); + assert_eq!(card.access_code, [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]); + } + + #[test] + fn request_card_consumes_the_card() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + + presenter.present_card(access_code, None, Duration::from_secs(5)); + + // First request should succeed + assert!(presenter.request_card(None).is_some()); + + // Second request should return None + assert!(presenter.request_card(None).is_none()); + } + + #[test] + fn request_card_filters_by_idm_requirement_true() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + + // Present card without IDm + presenter.present_card(access_code, None, Duration::from_secs(5)); + + // Request card that requires IDm should return None + assert!(presenter.request_card(Some(true)).is_none()); + } + + #[test] + fn request_card_filters_by_idm_requirement_false() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap(); + + // Present card with IDm + presenter.present_card(access_code, Some(idm), Duration::from_secs(5)); + + // Request card that requires no IDm should return None + assert!(presenter.request_card(Some(false)).is_none()); + } + + #[test] + fn request_card_returns_card_with_idm_when_required() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap(); + + presenter.present_card(access_code, Some(idm), Duration::from_secs(5)); + + let card = presenter.request_card(Some(true)); + assert!(card.is_some()); + assert!(card.unwrap().card_idm.is_some()); + } + + #[test] + fn expired_card_cannot_be_requested() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + + // Present card with very short validity + presenter.present_card(access_code, None, Duration::from_millis(1)); + + // Wait for expiry + std::thread::sleep(Duration::from_millis(10)); + + // Card should not be available + assert!(presenter.request_card(None).is_none()); + } + + #[test] + fn can_present_card_after_previous_expires() { + let presenter = create_presenter(); + let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap(); + + // Present first card with very short validity + presenter.present_card(access_code, None, Duration::from_millis(1)); + + // Wait for expiry + std::thread::sleep(Duration::from_millis(10)); + + // Should be able to present another card + match presenter.present_card(access_code, None, Duration::from_secs(5)) { + CardPresentResult::Accepted => {} + _ => panic!("Expected Accepted after previous card expired"), + } + } + } } \ No newline at end of file diff --git a/amnet-server/src/lib.rs b/amnet-server/src/lib.rs index 06cb6f3..25d2094 100644 --- a/amnet-server/src/lib.rs +++ b/amnet-server/src/lib.rs @@ -67,26 +67,21 @@ pub extern "C" fn aime_io_nfc_poll(unit_no: u8) -> i32 { } }; - if aime_code.is_empty() || !utils::is_valid_access_code(&aime_code) { - warn!("Access code file is either empty or contains invalid data"); + if aime_code.is_empty() { + warn!("Access code file is empty"); return 1; } - debug!("Access code read from file"); - let presenter_result = CardPresenter::instance().present_card( - &aime_code, - None, - std::time::Duration::from_secs(5), - ); + let Some(access_code) = utils::parse_access_code(&aime_code) else { + warn!("Invalid access code read from file"); + return 1; + }; - match presenter_result { - crate::card::CardPresentResult::Accepted => { + match CardPresenter::instance().present_card(access_code, None, std::time::Duration::from_secs(5)) { + card::CardPresentResult::Accepted => { return 0; }, - crate::card::CardPresentResult::Rejected(reason) => { - warn!("Read access code was rejected: {}", reason); - } - crate::card::CardPresentResult::SlotUnavailable(release_time) => { + card::CardPresentResult::SlotUnavailable(release_time) => { warn!("Access code was not accepted as there is already a card in the active slot until {:?}", release_time); } } diff --git a/amnet-server/src/utils.rs b/amnet-server/src/utils.rs index a6347ba..98f8254 100644 --- a/amnet-server/src/utils.rs +++ b/amnet-server/src/utils.rs @@ -41,25 +41,117 @@ pub (crate) fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> { Some(idm_bytes) } -pub (crate) fn is_valid_access_code(code: &String) -> bool { - if code.len() > 20 { - return false; - } +#[cfg(test)] +mod tests { + use super::*; - let cleaned_code: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + mod format_hex_bytes_tests { + use super::*; - if cleaned_code.len() != 20 { - return false; - } - - let mut seen_non_zero = false; - let is_all_digits = cleaned_code.chars().all(|c| { - if c != '0' { - seen_non_zero = true; + #[test] + fn formats_empty_bytes() { + assert_eq!(format_hex_bytes(&[]), ""); } - c.is_ascii_digit() - }); + #[test] + fn formats_access_code_bytes() { + let bytes = [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]; + assert_eq!(format_hex_bytes(&bytes), "0010 1234 5678 9012 3456"); + } + } - is_all_digits && seen_non_zero + mod parse_access_code_tests { + use super::*; + + #[test] + fn parses_full_20_digit_code() { + let code = "00101234567890123456".to_string(); + let result = parse_access_code(&code); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]); + } + + #[test] + fn parses_code_with_spaces() { + let code = "0010 1234 5678 9012 3456".to_string(); + let result = parse_access_code(&code); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]); + } + + #[test] + fn parses_code_with_leading_zeros() { + let code = "00000000000000001234".to_string(); // Valid 20-digit code with leading zeros + let result = parse_access_code(&code); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34]); + } + + #[test] + fn returns_none_for_invalid_hex() { + let code = "GHIJKLMNOPQRSTUVWXYZ".to_string(); + let result = parse_access_code(&code); + + assert!(result.is_none()); + } + + #[test] + fn parses_code_with_all_zeros() { + let code = "00000000000000000000".to_string(); + let result = parse_access_code(&code); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), [0x00; 10]); + } + } + + mod parse_idm_hex_tests { + use super::*; + + #[test] + fn parses_full_16_char_idm() { + let idm = "0102030405060708".to_string(); + let result = parse_idm_hex(&idm); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]); + } + + #[test] + fn parses_short_idm_returns_none() { + let idm = "1234".to_string(); + let result = parse_idm_hex(&idm); + + // IDm is too short - should fail + assert!(result.is_none()); + } + + #[test] + fn returns_none_for_too_long_idm() { + let idm = "01020304050607080910".to_string(); // 20 chars + let result = parse_idm_hex(&idm); + + assert!(result.is_none()); + } + + #[test] + fn returns_none_for_non_hex_characters() { + let idm = "0102GHIJ".to_string(); + let result = parse_idm_hex(&idm); + + assert!(result.is_none()); + } + + #[test] + fn parses_empty_idm_returns_none() { + let idm = "".to_string(); + let result = parse_idm_hex(&idm); + + // Empty IDm should fail + assert!(result.is_none()); + } + } } diff --git a/amnet-server/src/webserver.rs b/amnet-server/src/webserver.rs index 83738de..05dd729 100644 --- a/amnet-server/src/webserver.rs +++ b/amnet-server/src/webserver.rs @@ -24,6 +24,14 @@ pub fn build_server() -> Router { .layer(services) } +fn build_error_response(status: StatusCode, message: &str) -> Response { + let error = crate::webapi::ServerError { + error: message.to_string(), + }; + + (status, Json(error)).into_response() +} + async fn server_state_handler() -> Json { let config = Config::instance(); let metrics = SystemMetrics::instance(); @@ -49,23 +57,26 @@ async fn card_submission_handler( return build_error_response(StatusCode::BAD_REQUEST, "Access code is required"); } - if !utils::is_valid_access_code(&payload.access_code) { + let Some(access_code) = utils::parse_access_code(&payload.access_code) else { return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid access code provided"); - } + }; - if payload.card_idm_hex.as_ref().is_some_and(|idm| idm.chars().any(|c| !c.is_ascii_hexdigit())) { - return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided"); - } + let card_idm_bytes = if let Some(idm_hex) = payload.card_idm_hex.as_ref() { + if idm_hex.len() != 16 { + return build_error_response(StatusCode::BAD_REQUEST, "IDm must be a 16-char hex string"); + } - let presenter = CardPresenter::instance().present_card( - &payload.access_code, - payload.card_idm_hex.as_ref(), - Duration::from_secs(5), - ); + let Some(idm_bytes) = utils::parse_idm_hex(idm_hex) else { + return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided"); + }; - match presenter { + Some(idm_bytes) + } else { + None + }; + + match CardPresenter::instance().present_card(access_code, card_idm_bytes, Duration::from_secs(5)) { CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(), - CardPresentResult::Rejected(message) => build_error_response(StatusCode::FORBIDDEN, format!("Card rejected: {}", message).as_str()), CardPresentResult::SlotUnavailable(release_time) => { let now = Instant::now(); let wait_secs = if release_time > now { @@ -86,11 +97,3 @@ async fn card_submission_handler( } } } - -fn build_error_response(status: StatusCode, message: &str) -> Response { - let error = crate::webapi::ServerError { - error: message.to_string(), - }; - - (status, Json(error)).into_response() -}