From 2fe2e8cdbc3bb520994ff0944188f2fa657c546a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 17:49:08 +0100 Subject: v4.0.0: require HELLO MAJOR.MINOR lockstep before auth --- tests/server_core.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ tests/version.rs | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 tests/version.rs (limited to 'tests') diff --git a/tests/server_core.rs b/tests/server_core.rs index 6d6cffd..e2600e4 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -100,7 +100,16 @@ fn crash_state(response: &str) -> CrashState { serde_json::from_str(raw).expect("valid crash state") } +fn hello(core: &mut ServerCore, state: &mut ConnectionState) { + let response = core.handle_message( + state, + &format!("HELLO {}", zagan_bet::version::VERSION), + ); + assert!(response.starts_with("HELLO "), "handshake failed: {response}"); +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { + hello(core, state); assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), format!("REGISTERED {user}") @@ -108,6 +117,7 @@ fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { } fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { + hello(core, state); assert_eq!( core.handle_message(state, &format!("AUTH {user} credential")), format!("OK {user}") @@ -155,6 +165,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() { database::add_user(&conn, "legacy", "deadbeef").unwrap(); let mut state = ConnectionState::default(); + hello(&mut core, &mut state); assert_eq!( core.handle_message(&mut state, "AUTH legacy deadbeef"), "OK legacy" @@ -165,6 +176,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() { assert!(bcrypt::verify("deadbeef", &stored).unwrap()); let mut other = ConnectionState::default(); + hello(&mut core, &mut other); assert_eq!( core.handle_message(&mut other, "AUTH legacy wrong"), "ERROR Invalid credentials" @@ -876,6 +888,7 @@ fn an_insufficient_bet_leaves_no_reservation() { fn history_requires_login_and_valid_counts() { let (mut core, _path) = setup("history-guards", WIN3); let mut state = ConnectionState::default(); + hello(&mut core, &mut state); assert_eq!( core.handle_message(&mut state, "HISTORY"), @@ -928,3 +941,90 @@ fn history_lists_recent_plays_newest_first() { // Newest first: the timestamps must not increase across the list. assert!(records[0].at >= records[1].at); } + +#[test] +fn a_fresh_connection_must_greet_before_anything_else() { + let (mut core, _path) = setup("hello-gate", WIN3); + let mut state = ConnectionState::default(); + + assert_eq!( + core.handle_message(&mut state, "PING"), + "ERROR HELLO required" + ); + assert_eq!( + core.handle_message(&mut state, "AUTH alice x"), + "ERROR HELLO required" + ); + assert_eq!( + core.handle_message(&mut state, "PLAY_START slots 10"), + "ERROR HELLO required" + ); + assert!(!state.greeted()); +} + +#[test] +fn hello_reports_the_version_and_download_url() { + let (mut core, _path) = setup("hello-ok", WIN3); + let mut state = ConnectionState::default(); + + let response = core.handle_message( + &mut state, + &format!("HELLO {}", zagan_bet::version::VERSION), + ); + let hello = zagan_bet::version::parse_hello(&response).expect("hello reply"); + + assert_eq!(hello.version, zagan_bet::version::VERSION); + assert_eq!(hello.latest, zagan_bet::version::VERSION); + assert!(hello.url.starts_with("https://"), "url: {}", hello.url); + assert!(state.greeted()); + + // The gate is open now. + assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); +} + +#[test] +fn hello_refuses_a_different_major_or_minor() { + let (mut core, _path) = setup("hello-mismatch", WIN3); + let server = zagan_bet::version::Version::parse(zagan_bet::version::VERSION).unwrap(); + let versions = [ + format!("{}.{}.{}", server.major, server.minor + 1, server.patch), + format!("{}.{}.{}", server.major + 1, server.minor, server.patch), + ]; + + for version in versions { + let mut state = ConnectionState::default(); + let response = core.handle_message(&mut state, &format!("HELLO {version}")); + + assert!( + response.starts_with("ERROR Version mismatch"), + "response: {response}" + ); + assert!(!state.greeted(), "a refused handshake must not open the gate"); + assert_eq!( + core.handle_message(&mut state, "PING"), + "ERROR HELLO required" + ); + } +} + +#[test] +fn hello_accepts_a_patch_difference_and_rejects_bad_input() { + let (mut core, _path) = setup("hello-patch", WIN3); + let server = zagan_bet::version::Version::parse(zagan_bet::version::VERSION).unwrap(); + let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); + + let mut state = ConnectionState::default(); + assert!(core + .handle_message(&mut state, &format!("HELLO {patch}")) + .starts_with("HELLO ")); + + let mut other = ConnectionState::default(); + assert_eq!( + core.handle_message(&mut other, "HELLO"), + "ERROR Invalid HELLO format. Usage: HELLO " + ); + assert_eq!( + core.handle_message(&mut other, "HELLO nope"), + "ERROR Invalid version" + ); +} diff --git a/tests/version.rs b/tests/version.rs new file mode 100644 index 0000000..ef58601 --- /dev/null +++ b/tests/version.rs @@ -0,0 +1,71 @@ +use zagan_bet::version::{ + compatible, hello_line, is_newer, parse_hello, Version, VERSION, +}; + +#[test] +fn versions_parse_and_reject_nonsense() { + assert_eq!( + Version::parse("4.0.0"), + Some(Version { + major: 4, + minor: 0, + patch: 0 + }) + ); + assert_eq!( + Version::parse("v4.1.2"), + Some(Version { + major: 4, + minor: 1, + patch: 2 + }) + ); + assert_eq!( + Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), + Some((4, 0, 0)) + ); + assert_eq!( + Version::parse("4.0.0+build7").map(|v| v.patch), + Some(0) + ); + + for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { + assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); + } + + assert!(Version::parse(VERSION).is_some(), "the build version must parse"); +} + +#[test] +fn compatibility_is_major_and_minor_only() { + assert!(compatible("4.0.0", "4.0.9")); + assert!(compatible("4.2.7", "4.2.1")); + assert!(!compatible("4.0.0", "4.1.0")); + assert!(!compatible("4.0.0", "5.0.0")); + assert!(!compatible("nope", "4.0.0")); +} + +#[test] +fn newer_versions_are_ordered_by_major_minor_patch() { + assert!(is_newer("4.0.1", "4.0.0")); + assert!(is_newer("4.1.0", "4.0.9")); + assert!(is_newer("5.0.0", "4.9.9")); + assert!(!is_newer("4.0.0", "4.0.0")); + assert!(!is_newer("4.0.0", "4.0.1")); + assert!(!is_newer("nonsense", "4.0.0")); +} + +#[test] +fn the_hello_reply_round_trips() { + let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); + let reply = parse_hello(&line).expect("parse a generated reply"); + + assert_eq!(reply.version, "4.0.0"); + assert_eq!(reply.latest, "4.1.2"); + assert_eq!(reply.url, "https://zagan.example/"); + + assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); + assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); + assert!(parse_hello("PONG").is_none()); + assert!(parse_hello("HELLO").is_none()); +} -- cgit v1.2.3