use std::path::PathBuf; use rusqlite::Connection; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::registry::GameRegistry; use zagan_bet::server::games::slots::SlotsGame; fn setup(name: &str, grid: &str) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let registry = GameRegistry::new(); registry.register_game(SlotsGame::with_grid(grid)); // Low bcrypt cost keeps the tests fast. (ServerCore::with_registry(conn, registry, 4), path) } /// Column-major 5x3 grid with no winning line. const LOSS: &str = "CLOOGDSLCDCOGLS"; /// Top line is C C C D D; it pays 2x total bet. const WIN3: &str = "CCCCLLCLODGSDGS"; fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), format!("REGISTERED {user}") ); } fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("AUTH {user} credential")), format!("OK {user}") ); } #[test] fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { let (mut core, path) = setup("register", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); let stored = database::get_hashed_password(&conn, "alice").unwrap(); assert!(stored.starts_with("$2"), "not a bcrypt hash: {stored}"); assert_ne!(stored, "credential"); assert!(bcrypt::verify("credential", &stored).unwrap()); assert_eq!( core.handle_message(&mut state, "AUTH alice credential"), "OK alice" ); assert_eq!(state.user(), Some("alice")); } #[test] fn the_same_credential_gets_a_different_salt() { let (mut core, path) = setup("salt", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); register(&mut core, &mut state, "bob"); let conn = Connection::open(&path).unwrap(); let alice = database::get_hashed_password(&conn, "alice").unwrap(); let bob = database::get_hashed_password(&conn, "bob").unwrap(); assert_ne!(alice, bob); } #[test] fn legacy_sha256_rows_are_upgraded_on_login() { let (mut core, path) = setup("legacy", LOSS); let conn = Connection::open(&path).unwrap(); database::add_user(&conn, "legacy", "deadbeef").unwrap(); let mut state = ConnectionState::default(); assert_eq!( core.handle_message(&mut state, "AUTH legacy deadbeef"), "OK legacy" ); let stored = database::get_hashed_password(&conn, "legacy").unwrap(); assert!(stored.starts_with("$2"), "legacy hash was not upgraded"); assert!(bcrypt::verify("deadbeef", &stored).unwrap()); let mut other = ConnectionState::default(); assert_eq!( core.handle_message(&mut other, "AUTH legacy wrong"), "ERROR Invalid credentials" ); } #[test] fn duplicate_registration_returns_an_error() { let (mut core, _path) = setup("duplicate", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); assert_eq!( core.handle_message(&mut state, "REGISTER alice credential"), "ERROR User already exists" ); } #[test] fn a_malicious_username_cannot_change_another_balance() { let (mut core, _path) = setup("injection", LOSS); let mut admin = ConnectionState::default(); register(&mut core, &mut admin, "victim"); // No spaces, so it survives the three-part REGISTER/AUTH parser. let evil = "x';UPDATE/**/users/**/SET/**/balance=999999;--"; register(&mut core, &mut admin, evil); let mut evil_state = ConnectionState::default(); login(&mut core, &mut evil_state, evil); let response = core.handle_message(&mut evil_state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert_eq!(core.balance("victim"), Some(1000)); assert_eq!(core.balance(evil), Some(990)); assert_eq!(core.play_count(evil), 1); } #[test] fn a_failed_audit_insert_rolls_back_the_balance_change() { let (mut core, path) = setup("rollback", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TRIGGER fail_plays BEFORE INSERT ON plays BEGIN SELECT RAISE(ABORT, 'boom'); END;", ) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Failed to update balance" ); assert_eq!(core.balance("alice"), Some(1000)); assert_eq!(core.play_count("alice"), 0); assert_eq!(core.session_count(), 0); } #[test] fn spins_write_audit_rows_and_leave_no_sessions() { let (mut core, _path) = setup("audit", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); for _ in 0..3 { let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert_eq!(core.session_count(), 0); } assert_eq!(core.balance("alice"), Some(970)); assert_eq!(core.play_count("alice"), 3); } #[test] fn a_winning_spin_updates_the_balance_and_the_audit_row() { let (mut core, _path) = setup("win", WIN3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); assert!(response.contains(" 20 10 1010 "), "response: {response}"); assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); assert_eq!(core.balance("alice"), Some(1010)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn insufficient_balance_does_not_write_an_audit_row() { let (mut core, path) = setup("insufficient", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", []) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Insufficient balance" ); assert_eq!(core.balance("alice"), Some(5)); assert_eq!(core.play_count("alice"), 0); assert_eq!(core.session_count(), 0); } #[test] fn a_negative_balance_is_rejected_without_wrapping() { let (mut core, path) = setup("negative", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute( "UPDATE users SET balance = -5 WHERE username = 'alice'", [], ) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Invalid balance" ); assert_eq!(core.balance("alice"), Some(-5)); assert_eq!(core.play_count("alice"), 0); } #[test] fn unknown_games_are_rejected_before_a_session_is_created() { let (mut core, _path) = setup("unknown", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); assert_eq!( core.handle_message(&mut state, "PLAY_START nope 10"), "ERROR Unknown game type: nope" ); assert_eq!(core.session_count(), 0); }