summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/server_core.rs246
-rw-r--r--tests/slots.rs7
2 files changed, 253 insertions, 0 deletions
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 @@
1use std::path::PathBuf;
2
3use rusqlite::Connection;
4use zagan_bet::server::core::{ConnectionState, ServerCore};
5use zagan_bet::server::database;
6use zagan_bet::server::games::registry::GameRegistry;
7use zagan_bet::server::games::slots::SlotsGame;
8
9fn setup(name: &str, reels: &str) -> (ServerCore, String) {
10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
11 .join(format!("server-core-{name}.db"))
12 .to_string_lossy()
13 .into_owned();
14 let _ = std::fs::remove_file(&path);
15
16 let conn = Connection::open(&path).unwrap();
17 database::init_db(&conn);
18
19 let registry = GameRegistry::new();
20 registry.register_game(SlotsGame::with_reels(reels));
21
22 // Low bcrypt cost keeps the tests fast.
23 (ServerCore::with_registry(conn, registry, 4), path)
24}
25
26fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
27 assert_eq!(
28 core.handle_message(state, &format!("REGISTER {user} credential")),
29 format!("REGISTERED {user}")
30 );
31}
32
33fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
34 assert_eq!(
35 core.handle_message(state, &format!("AUTH {user} credential")),
36 format!("OK {user}")
37 );
38}
39
40#[test]
41fn register_stores_a_bcrypt_hash_and_auth_accepts_it() {
42 let (mut core, path) = setup("register", "CLO");
43 let mut state = ConnectionState::default();
44
45 register(&mut core, &mut state, "alice");
46
47 let conn = Connection::open(&path).unwrap();
48 let stored = database::get_hashed_password(&conn, "alice").unwrap();
49 assert!(stored.starts_with("$2"), "not a bcrypt hash: {stored}");
50 assert_ne!(stored, "credential");
51 assert!(bcrypt::verify("credential", &stored).unwrap());
52
53 assert_eq!(
54 core.handle_message(&mut state, "AUTH alice credential"),
55 "OK alice"
56 );
57 assert_eq!(state.user(), Some("alice"));
58}
59
60#[test]
61fn the_same_credential_gets_a_different_salt() {
62 let (mut core, path) = setup("salt", "CLO");
63 let mut state = ConnectionState::default();
64
65 register(&mut core, &mut state, "alice");
66 register(&mut core, &mut state, "bob");
67
68 let conn = Connection::open(&path).unwrap();
69 let alice = database::get_hashed_password(&conn, "alice").unwrap();
70 let bob = database::get_hashed_password(&conn, "bob").unwrap();
71 assert_ne!(alice, bob);
72}
73
74#[test]
75fn legacy_sha256_rows_are_upgraded_on_login() {
76 let (mut core, path) = setup("legacy", "CLO");
77 let conn = Connection::open(&path).unwrap();
78 database::add_user(&conn, "legacy", "deadbeef").unwrap();
79
80 let mut state = ConnectionState::default();
81 assert_eq!(
82 core.handle_message(&mut state, "AUTH legacy deadbeef"),
83 "OK legacy"
84 );
85
86 let stored = database::get_hashed_password(&conn, "legacy").unwrap();
87 assert!(stored.starts_with("$2"), "legacy hash was not upgraded");
88 assert!(bcrypt::verify("deadbeef", &stored).unwrap());
89
90 let mut other = ConnectionState::default();
91 assert_eq!(
92 core.handle_message(&mut other, "AUTH legacy wrong"),
93 "ERROR Invalid credentials"
94 );
95}
96
97#[test]
98fn duplicate_registration_returns_an_error() {
99 let (mut core, _path) = setup("duplicate", "CLO");
100 let mut state = ConnectionState::default();
101
102 register(&mut core, &mut state, "alice");
103 assert_eq!(
104 core.handle_message(&mut state, "REGISTER alice credential"),
105 "ERROR User already exists"
106 );
107}
108
109#[test]
110fn a_malicious_username_cannot_change_another_balance() {
111 let (mut core, _path) = setup("injection", "CLO");
112 let mut admin = ConnectionState::default();
113 register(&mut core, &mut admin, "victim");
114
115 // No spaces, so it survives the three-part REGISTER/AUTH parser.
116 let evil = "x';UPDATE/**/users/**/SET/**/balance=999999;--";
117 register(&mut core, &mut admin, evil);
118
119 let mut evil_state = ConnectionState::default();
120 login(&mut core, &mut evil_state, evil);
121
122 let response = core.handle_message(&mut evil_state, "PLAY_START slots 10");
123 assert!(
124 response.starts_with("GAME_RESULT"),
125 "spin failed: {response}"
126 );
127
128 assert_eq!(core.balance("victim"), Some(1000));
129 assert_eq!(core.balance(evil), Some(990));
130 assert_eq!(core.play_count(evil), 1);
131}
132
133#[test]
134fn a_failed_audit_insert_rolls_back_the_balance_change() {
135 let (mut core, path) = setup("rollback", "CLO");
136 let mut state = ConnectionState::default();
137 register(&mut core, &mut state, "alice");
138 login(&mut core, &mut state, "alice");
139
140 let conn = Connection::open(&path).unwrap();
141 conn.execute_batch(
142 "CREATE TRIGGER fail_plays BEFORE INSERT ON plays
143 BEGIN SELECT RAISE(ABORT, 'boom'); END;",
144 )
145 .unwrap();
146
147 assert_eq!(
148 core.handle_message(&mut state, "PLAY_START slots 10"),
149 "ERROR Failed to update balance"
150 );
151 assert_eq!(core.balance("alice"), Some(1000));
152 assert_eq!(core.play_count("alice"), 0);
153 assert_eq!(core.session_count(), 0);
154}
155
156#[test]
157fn spins_write_audit_rows_and_leave_no_sessions() {
158 let (mut core, _path) = setup("audit", "CLO");
159 let mut state = ConnectionState::default();
160 register(&mut core, &mut state, "alice");
161 login(&mut core, &mut state, "alice");
162
163 for _ in 0..3 {
164 let response = core.handle_message(&mut state, "PLAY_START slots 10");
165 assert!(
166 response.starts_with("GAME_RESULT"),
167 "spin failed: {response}"
168 );
169 assert_eq!(core.session_count(), 0);
170 }
171
172 assert_eq!(core.balance("alice"), Some(970));
173 assert_eq!(core.play_count("alice"), 3);
174}
175
176#[test]
177fn a_winning_spin_updates_the_balance_and_the_audit_row() {
178 let (mut core, _path) = setup("win", "CCC");
179 let mut state = ConnectionState::default();
180 register(&mut core, &mut state, "alice");
181 login(&mut core, &mut state, "alice");
182
183 let response = core.handle_message(&mut state, "PLAY_START slots 10");
184 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}");
185 assert!(response.contains(" 20 10 1010 "), "response: {response}");
186
187 assert_eq!(core.balance("alice"), Some(1010));
188 assert_eq!(core.play_count("alice"), 1);
189 assert_eq!(core.session_count(), 0);
190}
191
192#[test]
193fn insufficient_balance_does_not_write_an_audit_row() {
194 let (mut core, path) = setup("insufficient", "CLO");
195 let mut state = ConnectionState::default();
196 register(&mut core, &mut state, "alice");
197 login(&mut core, &mut state, "alice");
198
199 let conn = Connection::open(&path).unwrap();
200 conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", [])
201 .unwrap();
202
203 assert_eq!(
204 core.handle_message(&mut state, "PLAY_START slots 10"),
205 "ERROR Insufficient balance"
206 );
207 assert_eq!(core.balance("alice"), Some(5));
208 assert_eq!(core.play_count("alice"), 0);
209 assert_eq!(core.session_count(), 0);
210}
211
212#[test]
213fn a_negative_balance_is_rejected_without_wrapping() {
214 let (mut core, path) = setup("negative", "CLO");
215 let mut state = ConnectionState::default();
216 register(&mut core, &mut state, "alice");
217 login(&mut core, &mut state, "alice");
218
219 let conn = Connection::open(&path).unwrap();
220 conn.execute(
221 "UPDATE users SET balance = -5 WHERE username = 'alice'",
222 [],
223 )
224 .unwrap();
225
226 assert_eq!(
227 core.handle_message(&mut state, "PLAY_START slots 10"),
228 "ERROR Invalid balance"
229 );
230 assert_eq!(core.balance("alice"), Some(-5));
231 assert_eq!(core.play_count("alice"), 0);
232}
233
234#[test]
235fn unknown_games_are_rejected_before_a_session_is_created() {
236 let (mut core, _path) = setup("unknown", "CLO");
237 let mut state = ConnectionState::default();
238 register(&mut core, &mut state, "alice");
239 login(&mut core, &mut state, "alice");
240
241 assert_eq!(
242 core.handle_message(&mut state, "PLAY_START nope 10"),
243 "ERROR Unknown game type: nope"
244 );
245 assert_eq!(core.session_count(), 0);
246}
diff --git a/tests/slots.rs b/tests/slots.rs
index 12348c9..df4af28 100644
--- a/tests/slots.rs
+++ b/tests/slots.rs
@@ -48,6 +48,13 @@ fn no_match_returns_zero() {
48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); 48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0);
49} 49}
50 50
51#[test]
52fn an_enormous_bet_saturates_instead_of_overflowing() {
53 let game = make_game();
54 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX);
55 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX);
56}
57
51/// process_action produces a finished state with correct reels and win_type. 58/// process_action produces a finished state with correct reels and win_type.
52#[test] 59#[test]
53fn process_action_spin_sets_finished() { 60fn process_action_spin_sets_finished() {