diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8088ca52..182f38d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,9 +40,15 @@ jobs: working-directory: frontend run: trunk build --release + - name: Cargo Fmt + run: cargo fmt --all -- --check + - name: Cargo Check run: cargo check --workspace + - name: Cargo Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Cargo Test run: cargo test --workspace diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs index e36f989c..fdd65155 100644 --- a/backend/src/auth/middleware.rs +++ b/backend/src/auth/middleware.rs @@ -162,3 +162,32 @@ pub async fn security_headers_middleware(req: Request, next: Next) -> Response { response } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_bypass_matches_only_shell_paths() { + assert!(is_html_bypass("/")); + assert!(is_html_bypass("/index.html")); + assert!(is_html_bypass("/archive/game.html")); + assert!(!is_html_bypass("/api/game")); + assert!(!is_html_bypass("/index.html/data")); + } + + #[test] + fn auth_api_bypass_matches_only_public_endpoints() { + for path in [ + "/api/pin-required", + "/api/verify-pin", + "/api/auth-check", + "/api/logout", + ] { + assert!(is_auth_api_bypass(path)); + } + + assert!(!is_auth_api_bypass("/api/logout/all")); + assert!(!is_auth_api_bypass("/api/game")); + } +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 81ab82df..12673f97 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -147,3 +147,64 @@ pub fn is_authorized(headers: &HeaderMap, state: &AppState, pin: &str) -> bool { (None, None) => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn config(cookie_max_age_hours: i64) -> Arc { + Arc::new(ServerConfig { + port: 4502, + site_title: "Rustle".to_string(), + base_url: "http://localhost:4502".to_string(), + allowed_origins: String::new(), + pin: Some("1234".to_string()), + enable_translation: false, + enable_themes: true, + enable_print: true, + show_version: true, + show_github: true, + trust_proxy: false, + trusted_proxies: vec![], + max_attempts: 5, + lockout_time_minutes: 15, + cookie_max_age_hours, + shutdown_drain_seconds: 5, + }) + } + + #[test] + fn session_lifecycle_controls_cookie_authorization() { + let state = AppState::new(config(24)); + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, HeaderValue::from_static("other=a; pin=token")); + + assert!(!is_authorized(&headers, &state, "1234")); + state.register_session("token".to_string()); + assert!(state.session_is_valid("token")); + assert!(is_authorized(&headers, &state, "1234")); + state.unregister_session("token"); + assert!(!state.session_is_valid("token")); + assert!(!is_authorized(&headers, &state, "1234")); + } + + #[test] + fn expired_session_is_rejected() { + let state = AppState::new(config(0)); + state.register_session("expired".to_string()); + + assert!(!state.session_is_valid("expired")); + } + + #[test] + fn pin_header_authorization_requires_exact_value() { + let state = AppState::new(config(24)); + let mut headers = HeaderMap::new(); + + headers.insert("x-pin", HeaderValue::from_static("1234")); + assert!(is_authorized(&headers, &state, "1234")); + headers.insert("x-pin", HeaderValue::from_static("12345")); + assert!(!is_authorized(&headers, &state, "1234")); + } +} diff --git a/frontend/src/helpers/tests.rs b/frontend/src/helpers/tests.rs index 06a36a80..f4103202 100644 --- a/frontend/src/helpers/tests.rs +++ b/frontend/src/helpers/tests.rs @@ -45,6 +45,73 @@ fn test_guess_statuses_exact_match() { assert_eq!(statuses, vec![CharStatus::Correct; 5]); } +#[test] +fn test_guess_statuses_duplicate_letters() { + let cases = [ + ( + "APPLE", + "ALLEY", + vec![ + CharStatus::Correct, + CharStatus::Present, + CharStatus::Absent, + CharStatus::Present, + CharStatus::Absent, + ], + ), + ( + "BANAL", + "LLAMA", + vec![ + CharStatus::Present, + CharStatus::Absent, + CharStatus::Present, + CharStatus::Absent, + CharStatus::Present, + ], + ), + ( + "SHEEP", + "PEEPS", + vec![ + CharStatus::Present, + CharStatus::Present, + CharStatus::Correct, + CharStatus::Absent, + CharStatus::Present, + ], + ), + ]; + + for (solution, guess, expected) in cases { + assert_eq!(get_guess_statuses(solution, guess), expected); + } +} + +#[test] +fn test_guess_statuses_handles_length_mismatches() { + assert_eq!( + get_guess_statuses("CAT", "CATCH"), + vec![ + CharStatus::Correct, + CharStatus::Correct, + CharStatus::Correct, + CharStatus::Absent, + CharStatus::Absent, + ] + ); + assert_eq!( + get_guess_statuses("APPLE", "APP"), + vec![ + CharStatus::Correct, + CharStatus::Correct, + CharStatus::Correct, + ] + ); + assert_eq!(get_guess_statuses("", "A"), vec![CharStatus::Absent]); + assert!(get_guess_statuses("APPLE", "").is_empty()); +} + #[test] fn test_get_statuses_keyboard() { let guesses = vec!["PEARL".to_string(), "BOARD".to_string()]; @@ -57,6 +124,21 @@ fn test_get_statuses_keyboard() { assert_eq!(char_statuses.get(&'B'), Some(&CharStatus::Absent)); } +#[test] +fn test_keyboard_status_keeps_highest_precedence() { + let statuses = get_statuses( + "APPLE", + &[ + "PXXXX".to_string(), + "XPXXX".to_string(), + "XXXXX".to_string(), + ], + ); + + assert_eq!(statuses.get(&'P'), Some(&CharStatus::Correct)); + assert_eq!(statuses.get(&'X'), Some(&CharStatus::Absent)); +} + #[test] fn test_winning_word_case_insensitive() { assert!(is_winning_word("ApPlE", "aPpLe")); @@ -164,6 +246,31 @@ fn test_game_stats_streaks() { assert_eq!(stats.best_streak, 1); } +#[test] +fn test_game_stats_win_loss_boundaries() { + let first_try = add_stats_for_completed_game(default_stats(), 0); + assert_eq!(first_try.win_distribution, vec![1, 0, 0, 0, 0, 0]); + assert_eq!(first_try.games_failed, 0); + assert_eq!(first_try.success_rate, 100); + + let last_try = add_stats_for_completed_game(first_try, 5); + assert_eq!(last_try.win_distribution, vec![1, 0, 0, 0, 0, 1]); + assert_eq!(last_try.current_streak, 2); + assert_eq!(last_try.best_streak, 2); + assert_eq!(last_try.success_rate, 100); + + let loss = add_stats_for_completed_game(last_try, 6); + assert_eq!(loss.games_failed, 1); + assert_eq!(loss.current_streak, 0); + assert_eq!(loss.best_streak, 2); + assert_eq!(loss.success_rate, 67); + + let out_of_range_loss = add_stats_for_completed_game(loss, usize::MAX); + assert_eq!(out_of_range_loss.games_failed, 2); + assert_eq!(out_of_range_loss.total_games, 4); + assert_eq!(out_of_range_loss.success_rate, 50); +} + #[test] fn test_generate_emoji_grid() { let guesses = vec!["APPLE".to_string(), "WATER".to_string()];