chore(workspace): upgrade rand 0.9, edition 2024, expand server tests

- 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-29 00:36:12 +00:00
parent 18ac5adef5
commit 66695683eb
4 changed files with 142 additions and 4 deletions
+1 -1
View File
@@ -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
Generated
+1 -1
View File
@@ -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",
]
+2 -2
View File
@@ -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"
+138
View File
@@ -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)
// ---------------------------------------------------------------------------