From 63c2e2fc3ea299aee31b66d2bdd6ecd871df223a Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:44:41 +0100 Subject: v0.3.0: add server core, bcrypt migration and plays audit --- tests/server_core.rs | 246 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/server_core.rs (limited to 'tests/server_core.rs') diff --git a/tests/server_core.rs b/tests/server_core.rs new file mode 100644 index 0000000..fe2b3d3 --- /dev/null +++ b/tests/server_core.rs @@ -0,0 +1,246 @@ +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, reels: &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_reels(reels)); + + // Low bcrypt cost keeps the tests fast. + (ServerCore::with_registry(conn, registry, 4), path) +} + +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", "CLO"); + 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", "CLO"); + 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", "CLO"); + 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", "CLO"); + 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", "CLO"); + 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", "CLO"); + 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", "CLO"); + 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", "CCC"); + 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_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", "CLO"); + 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", "CLO"); + 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", "CLO"); + 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); +} -- cgit v1.2.3