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:
+1
-1
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user