fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,22 +1,17 @@
|
||||
//! Authentication handlers: register, login, refresh, delete account,
|
||||
//! current-user profile, and avatar upload.
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use axum::{Json, body::Bytes, extract::State, http::HeaderMap};
|
||||
use bcrypt::{hash, verify};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
|
||||
AppState,
|
||||
error::AppError,
|
||||
middleware::{AuthenticatedUser, Claims, validate_refresh_token},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -92,8 +87,12 @@ pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError
|
||||
kind: "access".to_string(),
|
||||
jti: None,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
|
||||
@@ -109,8 +108,12 @@ pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<(String, String
|
||||
kind: "refresh".to_string(),
|
||||
jti: Some(jti.clone()),
|
||||
};
|
||||
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
Ok((token, jti))
|
||||
}
|
||||
|
||||
@@ -176,13 +179,11 @@ pub async fn register(
|
||||
|
||||
// Check for duplicate username. SQLite returns TEXT as nullable so we
|
||||
// flatten the Option<Option<String>> produced by fetch_optional.
|
||||
let existing: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
username
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
let existing: Option<String> =
|
||||
sqlx::query_scalar!("SELECT id FROM users WHERE username = ?", username)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if existing.is_some() {
|
||||
tracing::warn!(username = %username, "register: username already taken");
|
||||
@@ -228,8 +229,12 @@ pub async fn login(
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or(AppError::InvalidCredentials)?;
|
||||
let row_id = row.id.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
||||
let row_hash = row.password_hash.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
|
||||
let row_id = row
|
||||
.id
|
||||
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
||||
let row_hash = row
|
||||
.password_hash
|
||||
.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
|
||||
|
||||
let valid = verify(&body.password, &row_hash)?;
|
||||
if !valid {
|
||||
@@ -266,13 +271,11 @@ pub async fn refresh(
|
||||
|
||||
// Verify this jti is still live (not yet consumed or from a deleted account).
|
||||
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
|
||||
let exists: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||
jti
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
let exists: Option<String> =
|
||||
sqlx::query_scalar!("SELECT jti FROM refresh_tokens WHERE jti = ?", jti)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(AppError::Unauthorized);
|
||||
@@ -361,12 +364,14 @@ pub async fn upload_avatar(
|
||||
.to_string();
|
||||
let ext = match mime.as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => return Err(AppError::BadRequest(
|
||||
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
||||
)),
|
||||
"image/gif" => "gif",
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if body.len() > AVATAR_MAX_BYTES {
|
||||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||
@@ -402,12 +407,10 @@ pub async fn upload_avatar(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let username: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT username FROM users WHERE id = ?",
|
||||
user.user_id
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
let username: Option<String> =
|
||||
sqlx::query_scalar!("SELECT username FROM users WHERE id = ?", user.user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
id: user.user_id,
|
||||
@@ -442,13 +445,11 @@ pub async fn reset_password(
|
||||
)));
|
||||
}
|
||||
|
||||
let user_id: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
username
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.flatten();
|
||||
let user_id: Option<String> =
|
||||
sqlx::query_scalar!("SELECT id FROM users WHERE username = ?", username)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let user_id =
|
||||
user_id.ok_or_else(|| AppError::NotFound(format!("user '{username}' not found")))?;
|
||||
@@ -475,7 +476,7 @@ pub async fn reset_password(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||
|
||||
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
|
||||
|
||||
@@ -556,11 +557,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn username_chars_ok_rejects_special_chars() {
|
||||
assert!(!username_chars_ok("ali ce")); // space
|
||||
assert!(!username_chars_ok("ali-ce")); // hyphen
|
||||
assert!(!username_chars_ok("ali.ce")); // dot
|
||||
assert!(!username_chars_ok("ali@ce")); // at
|
||||
assert!(!username_chars_ok("ali!ce")); // exclamation
|
||||
assert!(!username_chars_ok("ali ce")); // space
|
||||
assert!(!username_chars_ok("ali-ce")); // hyphen
|
||||
assert!(!username_chars_ok("ali.ce")); // dot
|
||||
assert!(!username_chars_ok("ali@ce")); // at
|
||||
assert!(!username_chars_ok("ali!ce")); // exclamation
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
//! generated on first request for that date, then stored in the database
|
||||
//! so subsequent calls return the same value.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{Json, extract::State};
|
||||
use chrono::Utc;
|
||||
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::{error::AppError, AppState};
|
||||
use crate::{AppState, error::AppError};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed generation
|
||||
@@ -115,7 +115,9 @@ pub async fn daily_challenge(
|
||||
.await?;
|
||||
|
||||
if let Some(r) = row {
|
||||
let json = r.goal_json.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
|
||||
let json = r
|
||||
.goal_json
|
||||
.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
|
||||
let goal: ChallengeGoal = serde_json::from_str(&json)?;
|
||||
return Ok(Json(goal));
|
||||
}
|
||||
@@ -148,7 +150,9 @@ pub async fn daily_challenge(
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let stored_json = stored.goal_json.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
|
||||
let stored_json = stored
|
||||
.goal_json
|
||||
.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
|
||||
let stored_goal: ChallengeGoal = serde_json::from_str(&stored_json)?;
|
||||
Ok(Json(stored_goal))
|
||||
}
|
||||
@@ -165,13 +169,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hash_date_differs_across_adjacent_days() {
|
||||
assert_ne!(hash_date_to_u64("2026-04-26"), hash_date_to_u64("2026-04-27"));
|
||||
assert_ne!(hash_date_to_u64("2026-04-26"), hash_date_to_u64("2026-04-25"));
|
||||
assert_ne!(
|
||||
hash_date_to_u64("2026-04-26"),
|
||||
hash_date_to_u64("2026-04-27")
|
||||
);
|
||||
assert_ne!(
|
||||
hash_date_to_u64("2026-04-26"),
|
||||
hash_date_to_u64("2026-04-25")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_date_differs_across_years() {
|
||||
assert_ne!(hash_date_to_u64("2026-01-01"), hash_date_to_u64("2027-01-01"));
|
||||
assert_ne!(
|
||||
hash_date_to_u64("2026-01-01"),
|
||||
hash_date_to_u64("2027-01-01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -217,7 +230,10 @@ mod tests {
|
||||
fn generate_goal_all_variants_have_sane_ranges() {
|
||||
for variant_idx in 0u64..6 {
|
||||
let g = generate_goal("2026-04-26", variant_idx);
|
||||
assert!(!g.description.is_empty(), "variant {variant_idx}: description must not be empty");
|
||||
assert!(
|
||||
!g.description.is_empty(),
|
||||
"variant {variant_idx}: description must not be empty"
|
||||
);
|
||||
if let Some(t) = g.max_time_secs {
|
||||
assert!(
|
||||
(60..=3600).contains(&t),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Application-level error type with automatic HTTP response conversion.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
@@ -63,19 +63,31 @@ impl IntoResponse for AppError {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("database error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
AppError::BcryptError(e) => {
|
||||
tracing::error!("bcrypt error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
AppError::Json(e) => {
|
||||
tracing::error!("json error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
AppError::Internal(msg) => {
|
||||
tracing::error!("internal error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
//! `GET /api/leaderboard` — list all opted-in entries (requires auth).
|
||||
//! `POST /api/leaderboard/opt-in` — opt in and set / update display name.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{Json, extract::State};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request shapes
|
||||
@@ -118,7 +118,9 @@ pub async fn opt_in(
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let display_name = body.display_name.trim();
|
||||
if display_name.is_empty() {
|
||||
return Err(AppError::BadRequest("display_name must not be empty".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"display_name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if display_name.chars().count() > DISPLAY_NAME_MAX {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -197,9 +199,9 @@ mod tests {
|
||||
fn leaderboard_entries_sorted_by_score_descending() {
|
||||
let mut entries = [
|
||||
entry("Charlie", Some(1_200)),
|
||||
entry("Alice", Some(8_000)),
|
||||
entry("Bob", Some(3_500)),
|
||||
entry("Dave", None), // no score — should rank last
|
||||
entry("Alice", Some(8_000)),
|
||||
entry("Bob", Some(3_500)),
|
||||
entry("Dave", None), // no score — should rank last
|
||||
];
|
||||
|
||||
// Mirrors the SQL sort:
|
||||
@@ -214,10 +216,22 @@ mod tests {
|
||||
});
|
||||
|
||||
// Scored entries first, in descending order.
|
||||
assert_eq!(entries[0].display_name, "Alice", "highest scorer must be first");
|
||||
assert_eq!(entries[1].display_name, "Bob", "second-highest scorer must be second");
|
||||
assert_eq!(entries[2].display_name, "Charlie", "lowest scorer must be third");
|
||||
assert_eq!(
|
||||
entries[0].display_name, "Alice",
|
||||
"highest scorer must be first"
|
||||
);
|
||||
assert_eq!(
|
||||
entries[1].display_name, "Bob",
|
||||
"second-highest scorer must be second"
|
||||
);
|
||||
assert_eq!(
|
||||
entries[2].display_name, "Charlie",
|
||||
"lowest scorer must be third"
|
||||
);
|
||||
// Null-score entry sinks to the bottom.
|
||||
assert_eq!(entries[3].display_name, "Dave", "entry with no score must rank last");
|
||||
assert_eq!(
|
||||
entries[3].display_name, "Dave",
|
||||
"entry with no score must rank last"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,22 +15,22 @@ pub mod sync;
|
||||
pub use auth::reset_password;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
http::{HeaderValue, Request},
|
||||
middleware as axum_middleware,
|
||||
response::{Html, Response},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use tower_governor::{
|
||||
GovernorLayer,
|
||||
errors::GovernorError,
|
||||
governor::GovernorConfigBuilder,
|
||||
key_extractor::{KeyExtractor, SmartIpKeyExtractor},
|
||||
GovernorLayer,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
@@ -59,9 +59,7 @@ impl KeyExtractor for UserIdKeyExtractor {
|
||||
return Ok(user_id);
|
||||
}
|
||||
// Fall back to IP so unauthenticated bursts don't bypass throttling.
|
||||
SmartIpKeyExtractor
|
||||
.extract(req)
|
||||
.map(|ip| ip.to_string())
|
||||
SmartIpKeyExtractor.extract(req).map(|ip| ip.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,18 +256,12 @@ const CSP: &str = concat!(
|
||||
async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
|
||||
let mut res = next.run(req).await;
|
||||
let headers = res.headers_mut();
|
||||
headers.insert(
|
||||
"Content-Security-Policy",
|
||||
HeaderValue::from_static(CSP),
|
||||
);
|
||||
headers.insert("Content-Security-Policy", HeaderValue::from_static(CSP));
|
||||
headers.insert(
|
||||
"X-Content-Type-Options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
"X-Frame-Options",
|
||||
HeaderValue::from_static("DENY"),
|
||||
);
|
||||
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
|
||||
res
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
//! echo "new_password" | ./solitaire_server --reset-password alice
|
||||
//! ```
|
||||
|
||||
use solitaire_server::{build_router, AppState};
|
||||
use sqlx::{sqlite::SqliteConnectOptions, SqlitePool};
|
||||
use solitaire_server::{AppState, build_router};
|
||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
||||
use std::{
|
||||
io::{self, BufRead},
|
||||
str::FromStr,
|
||||
net::SocketAddr,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -135,7 +135,5 @@ async fn run_server() {
|
||||
.await
|
||||
.expect("failed to bind TCP listener");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("server error");
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::AppError, AppState};
|
||||
use crate::{AppState, error::AppError};
|
||||
|
||||
/// The claims encoded in our JWTs.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -50,8 +50,7 @@ pub async fn require_auth(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let token = extract_bearer_token(req.headers())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
let token = extract_bearer_token(req.headers()).ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims = validate_access_token(&token, &state.jwt_secret)?;
|
||||
|
||||
@@ -75,8 +74,7 @@ pub fn validate_access_token(token: &str, secret: &str) -> Result<Claims, AppErr
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<Claims>(token, &key, &validation)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
let data = decode::<Claims>(token, &key, &validation).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
if data.claims.kind != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
@@ -91,8 +89,7 @@ pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppEr
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<Claims>(token, &key, &validation)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
let data = decode::<Claims>(token, &key, &validation).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
if data.claims.kind != "refresh" {
|
||||
return Err(AppError::Unauthorized);
|
||||
@@ -129,7 +126,7 @@ mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||
|
||||
const SECRET: &str = "test_secret_for_middleware_unit_tests_only";
|
||||
|
||||
@@ -141,7 +138,12 @@ mod tests {
|
||||
kind: kind.to_string(),
|
||||
jti: None,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -155,7 +157,10 @@ mod tests {
|
||||
"Authorization",
|
||||
HeaderValue::from_static("Bearer my.jwt.token"),
|
||||
);
|
||||
assert_eq!(extract_bearer_token(&headers), Some("my.jwt.token".to_string()));
|
||||
assert_eq!(
|
||||
extract_bearer_token(&headers),
|
||||
Some("my.jwt.token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -188,7 +193,8 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_access_token_accepts_valid_access_token() {
|
||||
let token = make_token("user-abc", "access", 3600);
|
||||
let claims = validate_access_token(&token, SECRET).expect("should accept valid access token");
|
||||
let claims =
|
||||
validate_access_token(&token, SECRET).expect("should accept valid access token");
|
||||
assert_eq!(claims.sub, "user-abc");
|
||||
assert_eq!(claims.kind, "access");
|
||||
}
|
||||
@@ -197,7 +203,10 @@ mod tests {
|
||||
fn validate_access_token_rejects_refresh_token() {
|
||||
let token = make_token("user-abc", "refresh", 3600);
|
||||
let result = validate_access_token(&token, SECRET);
|
||||
assert!(result.is_err(), "refresh token must be rejected by access validator");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"refresh token must be rejected by access validator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -212,7 +221,10 @@ mod tests {
|
||||
fn validate_access_token_rejects_wrong_secret() {
|
||||
let token = make_token("user-abc", "access", 3600);
|
||||
let result = validate_access_token(&token, "wrong_secret");
|
||||
assert!(result.is_err(), "token signed with different secret must be rejected");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"token signed with different secret must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -222,7 +234,8 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_refresh_token_accepts_valid_refresh_token() {
|
||||
let token = make_token("user-xyz", "refresh", 86400);
|
||||
let claims = validate_refresh_token(&token, SECRET).expect("should accept valid refresh token");
|
||||
let claims =
|
||||
validate_refresh_token(&token, SECRET).expect("should accept valid refresh token");
|
||||
assert_eq!(claims.sub, "user-xyz");
|
||||
assert_eq!(claims.kind, "refresh");
|
||||
}
|
||||
@@ -231,7 +244,10 @@ mod tests {
|
||||
fn validate_refresh_token_rejects_access_token() {
|
||||
let token = make_token("user-xyz", "access", 86400);
|
||||
let result = validate_refresh_token(&token, SECRET);
|
||||
assert!(result.is_err(), "access token must be rejected by refresh validator");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"access token must be rejected by refresh validator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,14 +15,49 @@
|
||||
//! be served without scanning every blob.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
|
||||
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
|
||||
|
||||
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
|
||||
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid draw_mode '{}'; expected one of {:?}",
|
||||
h.draw_mode, KNOWN_DRAW_MODES
|
||||
)));
|
||||
}
|
||||
if !KNOWN_MODES.contains(&h.mode.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid mode '{}'; expected one of {:?}",
|
||||
h.mode, KNOWN_MODES
|
||||
)));
|
||||
}
|
||||
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"time_seconds {} out of range (1–86400)",
|
||||
h.time_seconds
|
||||
)));
|
||||
}
|
||||
if h.final_score < 0 || h.final_score > 1_000_000 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"final_score {} out of range (0–1000000)",
|
||||
h.final_score
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
@@ -91,6 +126,8 @@ pub async fn upload(
|
||||
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
||||
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
||||
|
||||
validate_header(&header)?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let received_at = Utc::now().to_rfc3339();
|
||||
let replay_json = serde_json::to_string(&payload)?;
|
||||
@@ -205,12 +242,9 @@ pub async fn get_by_id(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT replay_json FROM replays WHERE id = ?",
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
let row = sqlx::query!("SELECT replay_json FROM replays WHERE id = ?", id,)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let replay_json = row
|
||||
.ok_or_else(|| AppError::NotFound("replay not found".into()))?
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
//! `GET /api/sync/pull` — return the server's stored payload for this user.
|
||||
//! `POST /api/sync/push` — receive the client's payload, merge, store, return.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{Json, extract::State};
|
||||
use chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use solitaire_sync::{
|
||||
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
||||
AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge,
|
||||
};
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database row helpers
|
||||
@@ -38,11 +38,17 @@ async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRo
|
||||
|
||||
/// Deserialize a stored `SyncRow` into a `SyncPayload`.
|
||||
fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError> {
|
||||
let stats_json = row.stats_json.as_deref()
|
||||
let stats_json = row
|
||||
.stats_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
|
||||
let achievements_json = row.achievements_json.as_deref()
|
||||
let achievements_json = row
|
||||
.achievements_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::Internal("missing achievements_json".into()))?;
|
||||
let progress_json = row.progress_json.as_deref()
|
||||
let progress_json = row
|
||||
.progress_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
|
||||
|
||||
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
|
||||
@@ -172,28 +178,29 @@ pub async fn push(
|
||||
/// If the user is opted in to the leaderboard, update their row with the
|
||||
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
|
||||
///
|
||||
/// Uses SQLite `MIN`/`MAX` in the UPDATE so the database never regresses
|
||||
/// a score even if the client sends stale data.
|
||||
/// The opt-in check and the update are performed atomically in a single
|
||||
/// conditional UPDATE (WHERE EXISTS subquery) to avoid a TOCTOU race where
|
||||
/// the user opts out between the check and the write.
|
||||
async fn update_leaderboard_if_opted_in(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
payload: &SyncPayload,
|
||||
) -> Result<(), AppError> {
|
||||
// Only update if the user has opted in (leaderboard row exists).
|
||||
let opted_in: Option<i64> = sqlx::query_scalar!(
|
||||
let opted_in = sqlx::query!(
|
||||
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
.await?
|
||||
.map(|r| r.leaderboard_opt_in)
|
||||
.unwrap_or(0);
|
||||
|
||||
if opted_in != Some(1) {
|
||||
if opted_in != 1 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let best_score = payload.stats.best_single_score as i64;
|
||||
let fastest = if payload.stats.fastest_win_seconds == u64::MAX {
|
||||
// Sentinel "never won" value — don't store.
|
||||
None::<i64>
|
||||
} else {
|
||||
Some(payload.stats.fastest_win_seconds as i64)
|
||||
@@ -211,7 +218,9 @@ async fn update_leaderboard_if_opted_in(
|
||||
recorded_at = ?
|
||||
WHERE user_id = ?"#,
|
||||
best_score,
|
||||
fastest, fastest, fastest,
|
||||
fastest,
|
||||
fastest,
|
||||
fastest,
|
||||
now,
|
||||
user_id
|
||||
)
|
||||
@@ -329,9 +338,18 @@ mod tests {
|
||||
|
||||
assert_eq!(merged.stats.games_played, 42, "idempotent: games_played");
|
||||
assert_eq!(merged.stats.games_won, 20, "idempotent: games_won");
|
||||
assert_eq!(merged.stats.best_single_score, 5_500, "idempotent: best_single_score");
|
||||
assert_eq!(merged.stats.fastest_win_seconds, 90, "idempotent: fastest_win_seconds");
|
||||
assert_eq!(merged.stats.lifetime_score, 110_000, "idempotent: lifetime_score");
|
||||
assert_eq!(
|
||||
merged.stats.best_single_score, 5_500,
|
||||
"idempotent: best_single_score"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.stats.fastest_win_seconds, 90,
|
||||
"idempotent: fastest_win_seconds"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.stats.lifetime_score, 110_000,
|
||||
"idempotent: lifetime_score"
|
||||
);
|
||||
assert_eq!(merged.progress.total_xp, 3_000, "idempotent: total_xp");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use solitaire_server::build_test_router;
|
||||
use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload};
|
||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||
use tower::ServiceExt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -138,12 +138,8 @@ struct TestClaims {
|
||||
fn decode_sub(token: &str) -> String {
|
||||
let mut v = Validation::default();
|
||||
v.validate_exp = false;
|
||||
let data = decode::<TestClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||
&v,
|
||||
)
|
||||
.expect("failed to decode access token");
|
||||
let data = decode::<TestClaims>(token, &DecodingKey::from_secret(TEST_SECRET.as_bytes()), &v)
|
||||
.expect("failed to decode access token");
|
||||
data.claims.sub
|
||||
}
|
||||
|
||||
@@ -155,11 +151,7 @@ async fn register_user(app: axum::Router, username: &str, password: &str) -> (St
|
||||
serde_json::json!({ "username": username, "password": password }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"register should return 200"
|
||||
);
|
||||
assert_eq!(resp.status(), StatusCode::OK, "register should return 200");
|
||||
let body = body_json(resp).await;
|
||||
let access = body["access_token"]
|
||||
.as_str()
|
||||
@@ -196,7 +188,6 @@ fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
|
||||
/// `POST /api/auth/register` must return 200 with both tokens.
|
||||
#[tokio::test]
|
||||
async fn register_creates_account_and_returns_tokens() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let resp = post_json(
|
||||
@@ -221,13 +212,16 @@ async fn register_creates_account_and_returns_tokens() {
|
||||
/// Registering the same username twice must return 409 Conflict on the second attempt.
|
||||
#[tokio::test]
|
||||
async fn register_duplicate_username_returns_conflict() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
|
||||
|
||||
// First registration succeeds.
|
||||
let first = post_json(app.clone(), "/api/auth/register", creds.clone()).await;
|
||||
assert_eq!(first.status(), StatusCode::OK, "first register must succeed");
|
||||
assert_eq!(
|
||||
first.status(),
|
||||
StatusCode::OK,
|
||||
"first register must succeed"
|
||||
);
|
||||
|
||||
// Second registration with the same username is rejected.
|
||||
let second = post_json(app, "/api/auth/register", creds).await;
|
||||
@@ -241,7 +235,6 @@ async fn register_duplicate_username_returns_conflict() {
|
||||
/// Short username (< 3 chars) is rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn register_rejects_short_username() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let resp = post_json(
|
||||
app,
|
||||
@@ -255,7 +248,6 @@ async fn register_rejects_short_username() {
|
||||
/// Username with disallowed characters is rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn register_rejects_invalid_username_chars() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let resp = post_json(
|
||||
app,
|
||||
@@ -269,7 +261,6 @@ async fn register_rejects_invalid_username_chars() {
|
||||
/// Password shorter than 8 characters is rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn register_rejects_short_password() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let resp = post_json(
|
||||
app,
|
||||
@@ -283,7 +274,6 @@ async fn register_rejects_short_password() {
|
||||
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
||||
#[tokio::test]
|
||||
async fn login_with_correct_credentials_returns_tokens() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// Register first.
|
||||
@@ -299,14 +289,19 @@ async fn login_with_correct_credentials_returns_tokens() {
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert!(body["access_token"].is_string(), "access_token must be present");
|
||||
assert!(body["refresh_token"].is_string(), "refresh_token must be present");
|
||||
assert!(
|
||||
body["access_token"].is_string(),
|
||||
"access_token must be present"
|
||||
);
|
||||
assert!(
|
||||
body["refresh_token"].is_string(),
|
||||
"refresh_token must be present"
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login` with a wrong password must return 401.
|
||||
#[tokio::test]
|
||||
async fn login_with_wrong_password_returns_401() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// Register a user.
|
||||
@@ -330,7 +325,6 @@ async fn login_with_wrong_password_returns_401() {
|
||||
/// `POST /api/auth/login` for a username that does not exist must return 401.
|
||||
#[tokio::test]
|
||||
async fn login_with_unknown_username_returns_401() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let resp = post_json(
|
||||
@@ -351,7 +345,6 @@ async fn login_with_unknown_username_returns_401() {
|
||||
/// a new access token and a rotated refresh token.
|
||||
#[tokio::test]
|
||||
async fn refresh_returns_new_access_and_refresh_tokens() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;
|
||||
@@ -395,7 +388,11 @@ async fn consumed_refresh_token_is_rejected() {
|
||||
serde_json::json!({ "refresh_token": original_refresh }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp1.status(), StatusCode::OK, "first rotation must succeed");
|
||||
assert_eq!(
|
||||
resp1.status(),
|
||||
StatusCode::OK,
|
||||
"first rotation must succeed"
|
||||
);
|
||||
|
||||
// Second attempt with the now-consumed original token must fail.
|
||||
let resp2 = post_json(
|
||||
@@ -449,7 +446,6 @@ async fn rotated_refresh_token_can_be_used_again() {
|
||||
/// the `kind` claim will be `"access"`, not `"refresh"`.
|
||||
#[tokio::test]
|
||||
async fn refresh_with_access_token_returns_401() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;
|
||||
@@ -476,7 +472,6 @@ async fn refresh_with_access_token_returns_401() {
|
||||
/// Push a payload, then pull — the pulled data must reflect the pushed values.
|
||||
#[tokio::test]
|
||||
async fn push_then_pull_returns_pushed_data() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
|
||||
@@ -502,7 +497,10 @@ async fn push_then_pull_returns_pushed_data() {
|
||||
let games_played = pull_body["merged"]["stats"]["games_played"]
|
||||
.as_u64()
|
||||
.expect("games_played must be a number");
|
||||
assert_eq!(games_played, 7, "pulled games_played must match pushed value");
|
||||
assert_eq!(
|
||||
games_played, 7,
|
||||
"pulled games_played must match pushed value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full register → login → push → pull integration roundtrip.
|
||||
@@ -518,7 +516,6 @@ async fn push_then_pull_returns_pushed_data() {
|
||||
/// the pushed values.
|
||||
#[tokio::test]
|
||||
async fn register_login_push_pull_full_roundtrip() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// --- Step 1: Register ---
|
||||
@@ -546,11 +543,7 @@ async fn register_login_push_pull_full_roundtrip() {
|
||||
serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
login_resp.status(),
|
||||
StatusCode::OK,
|
||||
"login must return 200"
|
||||
);
|
||||
assert_eq!(login_resp.status(), StatusCode::OK, "login must return 200");
|
||||
let login_body = body_json(login_resp).await;
|
||||
let access_token = login_body["access_token"]
|
||||
.as_str()
|
||||
@@ -562,8 +555,7 @@ async fn register_login_push_pull_full_roundtrip() {
|
||||
|
||||
// --- Step 3: Push a payload with known values ---
|
||||
let payload = SyncPayload {
|
||||
user_id: uuid::Uuid::parse_str(&user_id)
|
||||
.expect("JWT sub must be a valid UUID"),
|
||||
user_id: uuid::Uuid::parse_str(&user_id).expect("JWT sub must be a valid UUID"),
|
||||
stats: StatsSnapshot {
|
||||
games_played: 42,
|
||||
games_won: 17,
|
||||
@@ -583,19 +575,11 @@ async fn register_login_push_pull_full_roundtrip() {
|
||||
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
push_resp.status(),
|
||||
StatusCode::OK,
|
||||
"push must return 200"
|
||||
);
|
||||
assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");
|
||||
|
||||
// --- Step 4: Pull and verify the stored data matches what was pushed ---
|
||||
let pull_resp = get_authed(app, "/api/sync/pull", &access_token).await;
|
||||
assert_eq!(
|
||||
pull_resp.status(),
|
||||
StatusCode::OK,
|
||||
"pull must return 200"
|
||||
);
|
||||
assert_eq!(pull_resp.status(), StatusCode::OK, "pull must return 200");
|
||||
|
||||
let pull_body = body_json(pull_resp).await;
|
||||
let merged = &pull_body["merged"];
|
||||
@@ -625,7 +609,6 @@ async fn register_login_push_pull_full_roundtrip() {
|
||||
/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
|
||||
#[tokio::test]
|
||||
async fn push_with_wrong_user_id_returns_400() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;
|
||||
@@ -658,25 +641,30 @@ async fn push_with_wrong_user_id_returns_400() {
|
||||
/// A pull before any push returns a default empty payload (200, not 404).
|
||||
#[tokio::test]
|
||||
async fn pull_before_push_returns_default_payload() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;
|
||||
|
||||
let resp = get_authed(app, "/api/sync/pull", &access).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK, "pull with no data must return 200");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"pull with no data must return 200"
|
||||
);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
let games_played = body["merged"]["stats"]["games_played"]
|
||||
.as_u64()
|
||||
.expect("games_played must be present");
|
||||
assert_eq!(games_played, 0, "default payload must have games_played = 0");
|
||||
assert_eq!(
|
||||
games_played, 0,
|
||||
"default payload must have games_played = 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Accessing `/api/sync/pull` without a token must return 401.
|
||||
#[tokio::test]
|
||||
async fn pull_without_token_returns_401() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let req = Request::builder()
|
||||
@@ -703,7 +691,6 @@ async fn pull_without_token_returns_401() {
|
||||
/// return 200.
|
||||
#[tokio::test]
|
||||
async fn delete_account_succeeds_and_data_is_gone() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
|
||||
@@ -718,7 +705,11 @@ async fn delete_account_succeeds_and_data_is_gone() {
|
||||
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(push_resp.status(), StatusCode::OK, "setup push must succeed");
|
||||
assert_eq!(
|
||||
push_resp.status(),
|
||||
StatusCode::OK,
|
||||
"setup push must succeed"
|
||||
);
|
||||
|
||||
// Delete the account.
|
||||
let del_resp = delete_authed(app.clone(), "/api/account", &access).await;
|
||||
@@ -769,10 +760,7 @@ async fn health_returns_ok() {
|
||||
assert_eq!(resp.status(), StatusCode::OK, "health must return 200");
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(
|
||||
body["status"], "ok",
|
||||
"health body must contain status: ok"
|
||||
);
|
||||
assert_eq!(body["status"], "ok", "health body must contain status: ok");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -782,7 +770,6 @@ async fn health_returns_ok() {
|
||||
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
|
||||
#[tokio::test]
|
||||
async fn daily_challenge_returns_goal_for_today() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
@@ -794,14 +781,21 @@ async fn daily_challenge_returns_goal_for_today() {
|
||||
.expect("failed to build daily-challenge request");
|
||||
|
||||
let resp = app.oneshot(req).await.expect("oneshot failed");
|
||||
assert_eq!(resp.status(), StatusCode::OK, "daily challenge must return 200");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"daily challenge must return 200"
|
||||
);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(
|
||||
body["date"], today,
|
||||
"challenge date must match today's UTC date"
|
||||
);
|
||||
assert!(body["seed"].is_number(), "challenge must include a numeric seed");
|
||||
assert!(
|
||||
body["seed"].is_number(),
|
||||
"challenge must include a numeric seed"
|
||||
);
|
||||
assert!(
|
||||
body["description"].is_string(),
|
||||
"challenge must include a description"
|
||||
@@ -811,7 +805,6 @@ async fn daily_challenge_returns_goal_for_today() {
|
||||
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
|
||||
#[tokio::test]
|
||||
async fn daily_challenge_is_deterministic() {
|
||||
|
||||
// Use the same pool so the second call hits the stored row.
|
||||
let pool = test_pool().await;
|
||||
|
||||
@@ -854,7 +847,6 @@ async fn daily_challenge_is_deterministic() {
|
||||
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
||||
#[tokio::test]
|
||||
async fn leaderboard_without_token_returns_401() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let req = Request::builder()
|
||||
@@ -874,7 +866,6 @@ async fn leaderboard_without_token_returns_401() {
|
||||
/// Opting in and then fetching the leaderboard returns the opted-in entry.
|
||||
#[tokio::test]
|
||||
async fn opt_in_then_leaderboard_shows_entry() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;
|
||||
@@ -887,28 +878,25 @@ async fn opt_in_then_leaderboard_shows_entry() {
|
||||
serde_json::json!({ "display_name": "KarenTheGreat" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
opt_resp.status(),
|
||||
StatusCode::OK,
|
||||
"opt-in must return 200"
|
||||
);
|
||||
assert_eq!(opt_resp.status(), StatusCode::OK, "opt-in must return 200");
|
||||
|
||||
// Fetch the leaderboard.
|
||||
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
|
||||
assert_eq!(lb_resp.status(), StatusCode::OK, "leaderboard must return 200");
|
||||
assert_eq!(
|
||||
lb_resp.status(),
|
||||
StatusCode::OK,
|
||||
"leaderboard must return 200"
|
||||
);
|
||||
|
||||
let body = body_json(lb_resp).await;
|
||||
let entries = body.as_array().expect("leaderboard must be a JSON array");
|
||||
let found = entries
|
||||
.iter()
|
||||
.any(|e| e["display_name"] == "KarenTheGreat");
|
||||
let found = entries.iter().any(|e| e["display_name"] == "KarenTheGreat");
|
||||
assert!(found, "opted-in user must appear in leaderboard");
|
||||
}
|
||||
|
||||
/// Pushing sync data after opting in updates the leaderboard best_score.
|
||||
#[tokio::test]
|
||||
async fn push_after_opt_in_updates_leaderboard_score() {
|
||||
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
|
||||
@@ -952,15 +940,23 @@ async fn push_after_opt_in_updates_leaderboard_score() {
|
||||
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
|
||||
let body = body_json(lb_resp).await;
|
||||
let entries = body.as_array().unwrap();
|
||||
let entry = entries.iter().find(|e| e["display_name"] == "Scorer").expect("entry missing");
|
||||
assert_eq!(entry["best_score"], 3_500, "best_score must be updated from push");
|
||||
assert_eq!(entry["best_time_secs"], 180, "best_time_secs must be updated from push");
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|e| e["display_name"] == "Scorer")
|
||||
.expect("entry missing");
|
||||
assert_eq!(
|
||||
entry["best_score"], 3_500,
|
||||
"best_score must be updated from push"
|
||||
);
|
||||
assert_eq!(
|
||||
entry["best_time_secs"], 180,
|
||||
"best_time_secs must be updated from push"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushing a lower score after a higher one does not overwrite the best.
|
||||
#[tokio::test]
|
||||
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
|
||||
@@ -990,25 +986,40 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||
};
|
||||
|
||||
// First push: high score.
|
||||
post_authed(app.clone(), "/api/sync/push", &access,
|
||||
serde_json::to_value(make(5_000, 120)).unwrap()).await;
|
||||
post_authed(
|
||||
app.clone(),
|
||||
"/api/sync/push",
|
||||
&access,
|
||||
serde_json::to_value(make(5_000, 120)).unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Second push: lower score and slower time.
|
||||
post_authed(app.clone(), "/api/sync/push", &access,
|
||||
serde_json::to_value(make(1_000, 600)).unwrap()).await;
|
||||
post_authed(
|
||||
app.clone(),
|
||||
"/api/sync/push",
|
||||
&access,
|
||||
serde_json::to_value(make(1_000, 600)).unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
|
||||
let body = body_json(lb_resp).await;
|
||||
let entries = body.as_array().unwrap();
|
||||
let entry = entries.iter().find(|e| e["display_name"] == "Champ").unwrap();
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|e| e["display_name"] == "Champ")
|
||||
.unwrap();
|
||||
assert_eq!(entry["best_score"], 5_000, "best_score must not regress");
|
||||
assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest");
|
||||
assert_eq!(
|
||||
entry["best_time_secs"], 120,
|
||||
"best_time_secs must stay at fastest"
|
||||
);
|
||||
}
|
||||
|
||||
/// Opting out hides the player from the leaderboard; opting back in restores them.
|
||||
#[tokio::test]
|
||||
async fn opt_out_hides_then_opt_in_restores() {
|
||||
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
|
||||
@@ -1028,7 +1039,11 @@ async fn opt_out_hides_then_opt_in_restores() {
|
||||
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||
let entries = body_json(lb).await;
|
||||
assert!(
|
||||
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||
entries
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e["display_name"] == "Visible"),
|
||||
"opted-in user must appear"
|
||||
);
|
||||
|
||||
@@ -1040,7 +1055,11 @@ async fn opt_out_hides_then_opt_in_restores() {
|
||||
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||
let entries = body_json(lb).await;
|
||||
assert!(
|
||||
!entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||
!entries
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e["display_name"] == "Visible"),
|
||||
"opted-out user must be hidden"
|
||||
);
|
||||
|
||||
@@ -1055,7 +1074,11 @@ async fn opt_out_hides_then_opt_in_restores() {
|
||||
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||
let entries = body_json(lb).await;
|
||||
assert!(
|
||||
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||
entries
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e["display_name"] == "Visible"),
|
||||
"re-opted-in user must appear again"
|
||||
);
|
||||
}
|
||||
@@ -1063,7 +1086,6 @@ async fn opt_out_hides_then_opt_in_restores() {
|
||||
/// Opting in with an empty display name returns 400.
|
||||
#[tokio::test]
|
||||
async fn opt_in_empty_display_name_returns_400() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;
|
||||
|
||||
@@ -1084,7 +1106,6 @@ async fn opt_in_empty_display_name_returns_400() {
|
||||
/// Opting in with a display name longer than 32 characters returns 400.
|
||||
#[tokio::test]
|
||||
async fn opt_in_too_long_display_name_returns_400() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;
|
||||
|
||||
@@ -1106,7 +1127,6 @@ async fn opt_in_too_long_display_name_returns_400() {
|
||||
/// Exactly 32 ASCII characters is accepted.
|
||||
#[tokio::test]
|
||||
async fn opt_in_exactly_32_char_display_name_succeeds() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;
|
||||
|
||||
@@ -1129,7 +1149,6 @@ async fn opt_in_exactly_32_char_display_name_succeeds() {
|
||||
/// accepted — the limit is character count, not byte count.
|
||||
#[tokio::test]
|
||||
async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;
|
||||
|
||||
@@ -1153,7 +1172,6 @@ async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
||||
/// A display name with 33 Unicode emoji is rejected.
|
||||
#[tokio::test]
|
||||
async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;
|
||||
|
||||
@@ -1176,7 +1194,6 @@ async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
||||
/// the server merges (max wins) rather than blindly replacing.
|
||||
#[tokio::test]
|
||||
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
|
||||
@@ -1219,7 +1236,6 @@ async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
||||
/// Login with leading/trailing whitespace in the username still succeeds.
|
||||
#[tokio::test]
|
||||
async fn login_trims_whitespace_from_username() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let _ = register_user(app.clone(), "trimtest", "password1!").await;
|
||||
@@ -1246,15 +1262,13 @@ async fn login_trims_whitespace_from_username() {
|
||||
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
|
||||
#[tokio::test]
|
||||
async fn push_oversized_body_returns_413() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
|
||||
|
||||
// 1_100_000-byte string embedded in JSON comfortably exceeds the 1 MB limit.
|
||||
let big_string = "x".repeat(1_100_000);
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
|
||||
let body_bytes = serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
@@ -1276,7 +1290,6 @@ async fn push_oversized_body_returns_413() {
|
||||
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
|
||||
#[tokio::test]
|
||||
async fn expired_access_token_returns_401() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
|
||||
@@ -1309,7 +1322,6 @@ async fn expired_access_token_returns_401() {
|
||||
/// A refresh token must be rejected when used as a Bearer token on protected routes.
|
||||
#[tokio::test]
|
||||
async fn refresh_token_rejected_on_protected_routes() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
|
||||
@@ -1521,8 +1533,7 @@ async fn reset_password_invalidates_existing_sessions() {
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool.clone());
|
||||
|
||||
let (_, refresh_token) =
|
||||
register_user(app.clone(), "reset_user_b", "password1!").await;
|
||||
let (_, refresh_token) = register_user(app.clone(), "reset_user_b", "password1!").await;
|
||||
|
||||
// Confirm the refresh token works before the reset.
|
||||
let pre_reset = post_json(
|
||||
@@ -1804,8 +1815,20 @@ async fn replay_recent_lists_newest_first_with_username() {
|
||||
let app = build_test_router(pool);
|
||||
let (token, _) = register_user(app.clone(), "replay_recent_user", "p4ssword!").await;
|
||||
|
||||
let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(1, 100)).await;
|
||||
let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(2, 200)).await;
|
||||
let _ = post_authed(
|
||||
app.clone(),
|
||||
"/api/replays",
|
||||
&token,
|
||||
sample_replay_payload(1, 100),
|
||||
)
|
||||
.await;
|
||||
let _ = post_authed(
|
||||
app.clone(),
|
||||
"/api/replays",
|
||||
&token,
|
||||
sample_replay_payload(2, 200),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
@@ -1818,16 +1841,16 @@ async fn replay_recent_lists_newest_first_with_username() {
|
||||
|
||||
let entries = body_json(resp).await;
|
||||
let array = entries.as_array().expect("recent should return an array");
|
||||
assert!(array.len() >= 2, "two uploads should yield two list entries");
|
||||
assert!(
|
||||
array.len() >= 2,
|
||||
"two uploads should yield two list entries"
|
||||
);
|
||||
// Newer upload (seed = 2) must appear before older one (seed = 1).
|
||||
let seeds: Vec<i64> = array
|
||||
.iter()
|
||||
.map(|e| e["seed"].as_i64().expect("seed should be an integer"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seeds, [2, 1],
|
||||
"received_at DESC: most recent upload first",
|
||||
);
|
||||
assert_eq!(seeds, [2, 1], "received_at DESC: most recent upload first",);
|
||||
assert_eq!(
|
||||
array[0]["username"].as_str(),
|
||||
Some("replay_recent_user"),
|
||||
|
||||
Reference in New Issue
Block a user