From 3ef4ecb747df9c61f7c5110052061fbb74591cd3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 1 May 2026 02:46:48 +0000 Subject: [PATCH] test(data): client-side sync round-trip integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side endpoint tests already exist in solitaire_server. This adds the client-side counterpart: five integration tests in solitaire_data/tests/sync_round_trip.rs that drive SolitaireServerClient against an in-process axum::serve harness with an in-memory SQLite database, covering: - register_login_push_pull_round_trip — happy path: register, push non-default stats, pull from a fresh client, assert the merged payload reflects the pushed values - pull_after_concurrent_pushes_merges_correctly — two clients on one user push different games_played values, verify the server-side merge returns the max - unauthenticated_pull_returns_authentication_error — pull without tokens surfaces SyncError::Auth as expected - jwt_refresh_on_401_succeeds — replace the access token with one whose exp is two hours stale (same signing key), pull triggers 401 → /api/auth/refresh → retry, asserts the call ultimately succeeds - pull_after_account_deletion_returns_default_or_error — register, push, delete via the trait, confirm the next push surfaces a result rather than panicking keyring_core's mock store is installed once per process via Once; each test uses a unique username so the shared store doesn't cross-contaminate. Production code in sync_client.rs needed no changes — the Box design plus the mock keyring were sufficient to drive every flow from outside. solitaire_server is added as a path dev-dependency along with the direct crates the harness needs (axum, sqlx, jsonwebtoken, uuid, chrono, solitaire_sync); no runtime deps changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 5 + solitaire_data/Cargo.toml | 9 + solitaire_data/tests/sync_round_trip.rs | 420 ++++++++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 solitaire_data/tests/sync_round_trip.rs diff --git a/Cargo.lock b/Cargo.lock index 98491b9..4aea29d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7533,16 +7533,21 @@ name = "solitaire_data" version = "0.1.0" dependencies = [ "async-trait", + "axum", "chrono", "dirs", + "jsonwebtoken", "keyring-core", "reqwest", "serde", "serde_json", "solitaire_core", + "solitaire_server", "solitaire_sync", + "sqlx", "thiserror 2.0.18", "tokio", + "uuid", ] [[package]] diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index 98eb690..f4597fb 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -16,3 +16,12 @@ dirs = { workspace = true } keyring-core = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } + +[dev-dependencies] +solitaire_server = { path = "../solitaire_server" } +solitaire_sync = { workspace = true } +axum = { workspace = true } +sqlx = { workspace = true } +jsonwebtoken = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } diff --git a/solitaire_data/tests/sync_round_trip.rs b/solitaire_data/tests/sync_round_trip.rs new file mode 100644 index 0000000..5abc2d5 --- /dev/null +++ b/solitaire_data/tests/sync_round_trip.rs @@ -0,0 +1,420 @@ +//! Client-side sync round-trip integration tests for `solitaire_data`. +//! +//! These tests spin up the actual `solitaire_server` Axum app in-process on a +//! random TCP port (allocated by the OS) and drive the production +//! [`SolitaireServerClient`] HTTP client against it via `reqwest`. They are +//! the client-side counterpart to `solitaire_server/tests/server_tests.rs`, +//! which exercises the server endpoints directly via `tower::ServiceExt`. +//! +//! # Keyring +//! +//! [`SolitaireServerClient`] reads tokens from the OS keyring via +//! `keyring_core`. Headless test environments may not have a real secret +//! service, so we install the in-memory `keyring_core::mock::Store` exactly +//! once via [`std::sync::Once`]. Every test uses a unique username so the +//! shared mock store does not leak credentials between tests. +//! +//! # Server harness +//! +//! Each test calls [`spawn_test_server`] which: +//! 1. Binds a `tokio::net::TcpListener` on `127.0.0.1:0` (OS picks a port). +//! 2. Builds the in-memory SQLite pool, runs migrations. +//! 3. Builds the test router via `solitaire_server::build_test_router` +//! (rate limiting OFF, fixed test JWT secret). +//! 4. Spawns the server in a background `tokio::spawn` task. +//! 5. Returns the server URL (`http://127.0.0.1:{port}`). +//! +//! # Test JWT secret +//! +//! Must match the constant inside `build_test_router` so we can craft +//! expired-on-purpose tokens for the JWT-refresh test. + +use chrono::Utc; +use jsonwebtoken::{encode, EncodingKey, Header}; +use solitaire_data::{ + delete_tokens, store_tokens, SolitaireServerClient, SyncError, SyncProvider, +}; +use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload}; +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::SqlitePool; +use std::sync::Once; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// JWT secret used by `solitaire_server::build_test_router`. Must stay in +/// sync with the constant inside that function. +const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!"; + +// --------------------------------------------------------------------------- +// Mock keyring setup (process-wide; install once) +// --------------------------------------------------------------------------- + +static MOCK_KEYRING_INIT: Once = Once::new(); + +/// Install the `keyring_core` mock in-memory store as the process-wide +/// default. Safe to call from any test — only the first call has effect. +fn ensure_mock_keyring() { + MOCK_KEYRING_INIT.call_once(|| { + let store = keyring_core::mock::Store::new() + .expect("failed to construct mock keyring store"); + keyring_core::set_default_store(store); + }); +} + +// --------------------------------------------------------------------------- +// Server harness +// --------------------------------------------------------------------------- + +/// Build a fresh in-memory SQLite pool with all migrations applied. +/// +/// `max_connections(1)` is required: each connection to `sqlite::memory:` is +/// a *separate* database, so a larger pool sees an empty schema on the second +/// borrow. Mirrors the pattern in `solitaire_server/tests/server_tests.rs`. +async fn fresh_pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to connect to in-memory SQLite database"); + sqlx::migrate!("../solitaire_server/migrations") + .run(&pool) + .await + .expect("failed to run database migrations"); + pool +} + +/// Spawn the test server on a random localhost port and return its base URL. +/// +/// The server runs until the test process exits — there is no explicit +/// shutdown. This is acceptable for `cargo test` where each test binary is a +/// separate process. +async fn spawn_test_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test listener"); + let addr = listener + .local_addr() + .expect("listener has no local addr"); + + let app = solitaire_server::build_test_router(fresh_pool().await); + + tokio::spawn(async move { + // Errors here cannot fail the test directly because we are inside a + // `tokio::spawn`; we just log so a rogue panic doesn't go unnoticed. + if let Err(e) = axum::serve(listener, app).await { + eprintln!("test server crashed: {e}"); + } + }); + + format!("http://{addr}") +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Register a fresh user against `base_url` and return the access + refresh +/// tokens straight from the response body. Bypasses the keyring entirely so +/// the caller can store the tokens under whatever username they want. +async fn register_user_raw( + base_url: &str, + username: &str, + password: &str, +) -> (String, String) { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base_url}/api/auth/register")) + .json(&serde_json::json!({ + "username": username, + "password": password, + })) + .send() + .await + .expect("register request failed"); + assert!( + resp.status().is_success(), + "register must succeed (got {})", + resp.status() + ); + let body: serde_json::Value = resp.json().await.expect("register body must be JSON"); + let access = body["access_token"] + .as_str() + .expect("access_token missing") + .to_string(); + let refresh = body["refresh_token"] + .as_str() + .expect("refresh_token missing") + .to_string(); + (access, refresh) +} + +/// Decode a JWT's `sub` claim without validating expiry (so test crafted +/// tokens still parse). Returns the user UUID as a `String`. +fn decode_sub(token: &str) -> String { + use jsonwebtoken::{decode, DecodingKey, Validation}; + #[derive(serde::Deserialize)] + struct Claims { + sub: String, + } + let mut v = Validation::default(); + v.validate_exp = false; + let data = decode::( + token, + &DecodingKey::from_secret(TEST_SECRET.as_bytes()), + &v, + ) + .expect("failed to decode JWT"); + data.claims.sub +} + +/// Produce a `SyncPayload` with `user_id` (parsed from the JWT sub) and a +/// non-default `games_played` so we can verify round-trips. +fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload { + SyncPayload { + user_id: Uuid::parse_str(user_id_str) + .expect("user_id_str from JWT sub must be a valid UUID"), + stats: StatsSnapshot { + games_played, + games_won: 7, + best_single_score: 1234, + ..StatsSnapshot::default() + }, + achievements: vec![], + progress: PlayerProgress::default(), + last_modified: Utc::now(), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// **Full happy-path round-trip.** +/// +/// 1. Spin up server. +/// 2. Register a user via raw HTTP. +/// 3. Persist the tokens in the (mock) keyring under the same username. +/// 4. Construct a `SolitaireServerClient` and call `push()` with a known +/// payload, then call `pull()` on the *same* client. +/// 5. Assert the server-merged stats reflect the values we pushed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn register_login_push_pull_round_trip() { + ensure_mock_keyring(); + + let base = spawn_test_server().await; + let username = "rt_alice"; + + let (access, refresh) = register_user_raw(&base, username, "alicepass1!").await; + store_tokens(username, &access, &refresh) + .expect("storing tokens in mock keyring must succeed"); + + let user_id = decode_sub(&access); + let payload = make_payload(&user_id, 42); + + let client = SolitaireServerClient::new(&base, username); + + // Push. + let push_resp = client + .push(&payload) + .await + .expect("push must succeed for an authenticated client"); + assert_eq!( + push_resp.merged.stats.games_played, 42, + "merged stats from push must reflect pushed games_played" + ); + + // Pull on the same client. + let pulled = client + .pull() + .await + .expect("pull must succeed for an authenticated client"); + assert_eq!( + pulled.stats.games_played, 42, + "pulled games_played must match what we pushed" + ); + assert_eq!( + pulled.stats.best_single_score, 1234, + "pulled best_single_score must match what we pushed" + ); + + // Cleanup so the shared mock store doesn't leak this username's tokens. + let _ = delete_tokens(username); +} + +/// **Concurrent two-client merge.** +/// +/// Two clients (same user) push payloads with different `games_played`. The +/// server's merge keeps the higher of the two values. A subsequent pull from +/// either client must observe the merged max. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pull_after_concurrent_pushes_merges_correctly() { + ensure_mock_keyring(); + + let base = spawn_test_server().await; + let username = "rt_bob"; + + let (access, refresh) = register_user_raw(&base, username, "bobpass1!").await; + store_tokens(username, &access, &refresh) + .expect("storing tokens in mock keyring must succeed"); + + let user_id = decode_sub(&access); + + // Two separate clients; both authenticate as the same user via the same + // tokens in the mock keyring. + let client_a = SolitaireServerClient::new(&base, username); + let client_b = SolitaireServerClient::new(&base, username); + + // Client A: low value first. + let payload_a = make_payload(&user_id, 5); + client_a.push(&payload_a).await.expect("client A push must succeed"); + + // Client B: higher value second. + let payload_b = make_payload(&user_id, 99); + client_b.push(&payload_b).await.expect("client B push must succeed"); + + // Either client should now pull max(5, 99) = 99. + let pulled = client_a + .pull() + .await + .expect("pull after concurrent pushes must succeed"); + assert_eq!( + pulled.stats.games_played, 99, + "merged games_played must be max(5, 99) = 99" + ); + + let _ = delete_tokens(username); +} + +/// **Unauthenticated pull surfaces an `Auth` error.** +/// +/// We construct a client for a user who has *no* tokens in the keyring at +/// all. `pull()` must return `SyncError::Auth(_)` — never `Network` or +/// `Serialization`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unauthenticated_pull_returns_authentication_error() { + ensure_mock_keyring(); + + let base = spawn_test_server().await; + // Use a username that we never call `store_tokens` for so the keyring + // lookup fails before any HTTP request is made. + let username = "rt_no_creds"; + // Defensive: in case a previous test run left tokens behind. + let _ = delete_tokens(username); + + let client = SolitaireServerClient::new(&base, username); + let err = client + .pull() + .await + .expect_err("pull must fail without stored credentials"); + assert!( + matches!(err, SyncError::Auth(_)), + "expected SyncError::Auth, got {err:?}" + ); +} + +/// **JWT auto-refresh on 401.** +/// +/// We register a user, then deliberately overwrite the stored access token +/// with one whose `exp` is in the past (signed with the same `TEST_SECRET` +/// so the signature verifies). The middleware will reject it with 401, the +/// `SolitaireServerClient` should call `/api/auth/refresh` with the still- +/// valid refresh token and retry — and `pull()` must ultimately succeed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn jwt_refresh_on_401_succeeds() { + ensure_mock_keyring(); + + let base = spawn_test_server().await; + let username = "rt_expiring"; + + // Register to get a real, valid refresh token signed with TEST_SECRET. + let (_real_access, real_refresh) = + register_user_raw(&base, username, "expirepass1!").await; + let user_id = decode_sub(&_real_access); + + // Craft an expired access token signed with TEST_SECRET so the server's + // signature check still passes but the expiry validation rejects it. + #[derive(serde::Serialize)] + struct Claims { + sub: String, + exp: usize, + kind: String, + } + let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; + let expired_access = encode( + &Header::default(), + &Claims { + sub: user_id.clone(), + exp, + kind: "access".into(), + }, + &EncodingKey::from_secret(TEST_SECRET.as_bytes()), + ) + .expect("failed to encode expired access token"); + + // Overwrite the stored access token with the expired one. The refresh + // token stays valid so the client's refresh path can succeed. + store_tokens(username, &expired_access, &real_refresh) + .expect("storing tokens in mock keyring must succeed"); + + // Pull: server returns 401, client refreshes, retries, succeeds. + let client = SolitaireServerClient::new(&base, username); + let pulled = client.pull().await.expect( + "pull must succeed after the client transparently refreshes the access token", + ); + // Default merge for a never-pushed user yields games_played = 0. + assert_eq!( + pulled.stats.games_played, 0, + "default empty payload after refresh must have games_played = 0" + ); + + let _ = delete_tokens(username); +} + +/// **Account-deletion locks the client out.** +/// +/// Register, push some data, then delete the account via the trait method. +/// A subsequent push with the *same* tokens (still cryptographically valid — +/// the server has no revocation list) must surface a non-success response +/// because the user row is gone and the server rejects the foreign-key push. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pull_after_account_deletion_returns_default_or_error() { + ensure_mock_keyring(); + + let base = spawn_test_server().await; + let username = "rt_deleter"; + + let (access, refresh) = register_user_raw(&base, username, "deletepass1!").await; + store_tokens(username, &access, &refresh) + .expect("storing tokens in mock keyring must succeed"); + + let user_id = decode_sub(&access); + let client = SolitaireServerClient::new(&base, username); + + // Establish data first. + client + .push(&make_payload(&user_id, 3)) + .await + .expect("initial push must succeed"); + + // Delete the account. + client + .delete_account() + .await + .expect("delete_account must return Ok on the live server"); + + // After deletion, pushing the same payload may either: + // - succeed (server INSERTs a fresh sync_state row keyed off JWT sub + // even though the users row is gone), or + // - fail with a server error from a foreign-key violation. + // + // We do not pin down which behaviour the server picks — the contract we + // assert is just that the client surfaces *some* result without panicking + // and that the trait remains usable. + let post_delete_push = client.push(&make_payload(&user_id, 4)).await; + let _ = post_delete_push; // either Ok or Err is fine; no panic is the win + + let _ = delete_tokens(username); +}