From 66695683eb9a5c554944fcdf673603f71090bdae Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 29 Apr 2026 00:36:12 +0000 Subject: [PATCH] chore(workspace): upgrade rand 0.9, edition 2024, expand server tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rand "0.8" → "0.9": StdRng/SliceRandom API unchanged; 142 core tests pass - edition "2021" → "2024" workspace-wide: no gen keyword conflicts found; 204 tests (core + sync) pass clean with zero warnings - ARCHITECTURE.md: Edition 2021 → Edition 2024 in header - solitaire_server tests: add 5 new integration tests covering refresh-with-garbage-token, expired-refresh-token, push-without-token, delete-account-without-token, and leaderboard-authenticated-but-empty Co-Authored-By: Claude Sonnet 4.6 --- ARCHITECTURE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 4 +- solitaire_server/tests/server_tests.rs | 138 +++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8691bb9..b801cbc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Solitaire Quest — Architecture Document > **Version:** 1.1 -> **Language:** Rust (Edition 2021) +> **Language:** Rust (Edition 2024) > **Engine:** Bevy (latest stable) > **Last Updated:** 2026-04-29 diff --git a/Cargo.lock b/Cargo.lock index a689f84..f2c784f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7522,7 +7522,7 @@ name = "solitaire_core" version = "0.1.0" dependencies = [ "chrono", - "rand 0.8.6", + "rand 0.9.4", "serde", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 762769f..d96d623 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -edition = "2021" +edition = "2024" version = "0.1.0" license = "MIT" @@ -21,7 +21,7 @@ serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "2" -rand = "0.8" +rand = "0.9" async-trait = "0.1" tokio = { version = "1", features = ["full"] } dirs = "6" diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index 2e02d88..8e79284 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -1249,6 +1249,144 @@ async fn refresh_token_rejected_on_protected_routes() { ); } +// --------------------------------------------------------------------------- +// Additional auth refresh edge-case tests +// --------------------------------------------------------------------------- + +/// `POST /api/auth/refresh` with a completely invalid (non-JWT) string must +/// return 401 — the token cannot be decoded at all. +#[tokio::test] +async fn refresh_with_garbage_token_returns_401() { + let app = build_test_router(test_pool().await); + + let resp = post_json( + app, + "/api/auth/refresh", + serde_json::json!({ "refresh_token": "this.is.not.a.jwt" }), + ) + .await; + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "garbage refresh token must return 401" + ); +} + +/// `POST /api/auth/refresh` with an expired (but correctly signed) refresh +/// token must return 401 — `exp` is in the past. +#[tokio::test] +async fn refresh_with_expired_refresh_token_returns_401() { + let app = build_test_router(test_pool().await); + + // Craft a refresh token that expired 2 hours ago, signed with the same + // secret that `build_test_router` uses, so the signature is valid but the + // expiry check must still reject it. + #[derive(serde::Serialize)] + struct ExpiredRefreshClaims { + sub: String, + exp: usize, + kind: String, + } + let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; + let expired_token = encode( + &Header::default(), + &ExpiredRefreshClaims { + sub: "00000000-0000-0000-0000-000000000000".into(), + exp, + kind: "refresh".into(), + }, + &EncodingKey::from_secret(TEST_SECRET.as_bytes()), + ) + .unwrap(); + + let resp = post_json( + app, + "/api/auth/refresh", + serde_json::json!({ "refresh_token": expired_token }), + ) + .await; + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "expired refresh token must return 401" + ); +} + +// --------------------------------------------------------------------------- +// Additional no-auth / missing-token tests +// --------------------------------------------------------------------------- + +/// Accessing `POST /api/sync/push` with no Authorization header must return 401. +#[tokio::test] +async fn push_without_token_returns_401() { + let app = build_test_router(test_pool().await); + + let req = Request::builder() + .method("POST") + .uri("/api/sync/push") + .header("content-type", "application/json") + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::from(b"{}".as_ref())) + .expect("failed to build unauthenticated POST request"); + + let resp = app.oneshot(req).await.expect("oneshot failed"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "missing token on push must return 401" + ); +} + +/// Accessing `DELETE /api/account` with no Authorization header must return 401. +#[tokio::test] +async fn delete_account_without_token_returns_401() { + let app = build_test_router(test_pool().await); + + let req = Request::builder() + .method("DELETE") + .uri("/api/account") + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::empty()) + .expect("failed to build unauthenticated DELETE request"); + + let resp = app.oneshot(req).await.expect("oneshot failed"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "missing token on DELETE /api/account must return 401" + ); +} + +// --------------------------------------------------------------------------- +// Leaderboard — authenticated empty-array test +// --------------------------------------------------------------------------- + +/// `GET /api/leaderboard` with a valid JWT but no opted-in players returns 200 +/// with an empty JSON array. +#[tokio::test] +async fn leaderboard_with_valid_token_returns_empty_array_when_no_opts() { + let app = build_test_router(test_pool().await); + + // Register a user to get a valid token — do NOT opt in to the leaderboard. + let (access, _) = register_user(app.clone(), "no_opt_user", "password1!").await; + + let resp = get_authed(app, "/api/leaderboard", &access).await; + assert_eq!(resp.status(), StatusCode::OK, "leaderboard must return 200"); + + let body = body_json(resp).await; + assert!( + body.is_array(), + "leaderboard body must be a JSON array even when empty" + ); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "leaderboard must be empty when no players have opted in" + ); +} + // --------------------------------------------------------------------------- // Rate-limiting test (uses the production router with rate limiting enabled) // ---------------------------------------------------------------------------