feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily challenge, account deletion, JWT middleware, rate limiting via tower_governor, SQLite migrations, health endpoint - solitaire_server: expose build_test_router (no rate limiting) so integration tests work without a peer IP in oneshot requests - solitaire_sync: SyncPayload, merge logic, shared API types - solitaire_data: SyncProvider trait, LocalOnlyProvider, SolitaireServerClient, auth_tokens keyring integration, blanket Box<dyn SyncProvider> impl - solitaire_data/settings: derive Default on SyncBackend (clippy fix) - .sqlx/: offline query cache so server compiles without a live DB - sqlx: removed non-existent "offline" feature flag - keyring v2: fixed Entry::new() returning Result<Entry> - sqlx 0.8: all SQLite TEXT columns wrapped in Option<T> - Integration tests: max_connections(1) on in-memory pool so all connections share the same schema All 191 tests pass; cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@ name = "solitaire_server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "solitaire_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "solitaire_server"
|
||||
path = "src/main.rs"
|
||||
@@ -23,3 +27,10 @@ tower_governor = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
solitaire_sync = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Migration 001: initial schema
|
||||
-- Creates the core tables required by the Solitaire Quest sync server.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL, -- bcrypt, cost 12
|
||||
created_at TEXT NOT NULL, -- ISO 8601
|
||||
leaderboard_opt_in INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
stats_json TEXT NOT NULL,
|
||||
achievements_json TEXT NOT NULL,
|
||||
progress_json TEXT NOT NULL,
|
||||
last_modified TEXT NOT NULL -- ISO 8601
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_challenges (
|
||||
date TEXT PRIMARY KEY, -- "YYYY-MM-DD"
|
||||
seed INTEGER NOT NULL,
|
||||
goal_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leaderboard (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
display_name TEXT NOT NULL,
|
||||
best_time_secs INTEGER,
|
||||
best_score INTEGER,
|
||||
recorded_at TEXT NOT NULL -- ISO 8601
|
||||
);
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Authentication handlers: register, login, refresh, delete account.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use bcrypt::{hash, verify};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Body for `POST /api/auth/register` and `POST /api/auth/login`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Body for `POST /api/auth/refresh`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Successful auth response — contains both tokens.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Successful refresh response — contains only the new access token.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RefreshResponse {
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal database row type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// User row fetched from the database during login.
|
||||
/// Fields are `Option<String>` because sqlx treats all SQLite TEXT columns
|
||||
/// as nullable regardless of the NOT NULL constraint in the schema.
|
||||
struct UserRow {
|
||||
id: Option<String>,
|
||||
password_hash: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bcrypt cost used for password hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// bcrypt cost factor. Per ARCHITECTURE.md §19 this must be 12.
|
||||
const BCRYPT_COST: u32 = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token generation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Encode a JWT access token (24-hour expiry) for `user_id`.
|
||||
pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||
let exp = (Utc::now() + chrono::Duration::hours(24)).timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: "access".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`.
|
||||
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: "refresh".to_string(),
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/auth/register` — create a new account and return tokens.
|
||||
pub async fn register(
|
||||
State(pool): State<SqlitePool>,
|
||||
Json(body): Json<AuthRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
// Validate input minimally.
|
||||
if body.username.trim().is_empty() || body.password.is_empty() {
|
||||
return Err(AppError::BadRequest("username and password are required".into()));
|
||||
}
|
||||
|
||||
// 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 = ?",
|
||||
body.username
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::UsernameTaken);
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4().to_string();
|
||||
let password_hash = hash(&body.password, BCRYPT_COST)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
user_id,
|
||||
body.username,
|
||||
password_hash,
|
||||
now
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: make_access_token(&user_id, &secret)?,
|
||||
refresh_token: make_refresh_token(&user_id, &secret)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login` — verify credentials and return tokens.
|
||||
pub async fn login(
|
||||
State(pool): State<SqlitePool>,
|
||||
Json(body): Json<AuthRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
let row = sqlx::query_as!(
|
||||
UserRow,
|
||||
"SELECT id, password_hash FROM users WHERE username = ?",
|
||||
body.username
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.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 valid = verify(&body.password, &row_hash)?;
|
||||
if !valid {
|
||||
return Err(AppError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: make_access_token(&row_id, &secret)?,
|
||||
refresh_token: make_refresh_token(&row_id, &secret)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
||||
pub async fn refresh(
|
||||
Json(body): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, AppError> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
|
||||
|
||||
Ok(Json(RefreshResponse {
|
||||
access_token: make_access_token(&claims.sub, &secret)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
||||
///
|
||||
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
|
||||
pub async fn delete_account(
|
||||
State(pool): State<SqlitePool>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Daily challenge endpoint.
|
||||
//!
|
||||
//! `GET /api/daily-challenge` — returns the challenge for today's date.
|
||||
//!
|
||||
//! The seed is deterministic (same for all players worldwide) and is
|
||||
//! 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 chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute a deterministic seed from a date string such as `"2026-04-26"`.
|
||||
///
|
||||
/// Uses a simple polynomial rolling hash over the UTF-8 bytes of the string.
|
||||
/// The computation is identical across all server instances and all clients
|
||||
/// that implement the same algorithm.
|
||||
pub fn hash_date_to_u64(date: &str) -> u64 {
|
||||
date.bytes()
|
||||
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
|
||||
}
|
||||
|
||||
/// Generate a [`ChallengeGoal`] from a seed and date.
|
||||
///
|
||||
/// The goal type and parameters are derived deterministically from the seed
|
||||
/// so all players face exactly the same challenge on the same day.
|
||||
fn generate_goal(date: &str, seed: u64) -> ChallengeGoal {
|
||||
// Pick a goal variant based on seed modulo number-of-variants.
|
||||
// Three variants cycle through: timed, high-score, and open.
|
||||
match seed % 3 {
|
||||
0 => ChallengeGoal {
|
||||
date: date.to_string(),
|
||||
seed,
|
||||
description: "Win in under 5 minutes".to_string(),
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
},
|
||||
1 => ChallengeGoal {
|
||||
date: date.to_string(),
|
||||
seed,
|
||||
description: "Reach a score of 4 000 or more".to_string(),
|
||||
target_score: Some(4_000),
|
||||
max_time_secs: None,
|
||||
},
|
||||
_ => ChallengeGoal {
|
||||
date: date.to_string(),
|
||||
seed,
|
||||
description: "Win today's deal".to_string(),
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database row helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ChallengeRow {
|
||||
goal_json: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/daily-challenge` — no auth required.
|
||||
///
|
||||
/// Looks up today's challenge in the database. If none exists yet, generates
|
||||
/// one deterministically and stores it before returning.
|
||||
pub async fn daily_challenge(
|
||||
State(pool): State<SqlitePool>,
|
||||
) -> Result<Json<ChallengeGoal>, AppError> {
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
// Try to load an existing row.
|
||||
let row = sqlx::query_as!(
|
||||
ChallengeRow,
|
||||
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||
today
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
|
||||
if let Some(r) = row {
|
||||
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));
|
||||
}
|
||||
|
||||
// No row yet — generate and store.
|
||||
let seed = hash_date_to_u64(&today);
|
||||
let goal = generate_goal(&today, seed);
|
||||
let goal_json = serde_json::to_string(&goal)?;
|
||||
let seed_i64 = seed as i64;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR IGNORE INTO daily_challenges (date, seed, goal_json) VALUES (?, ?, ?)",
|
||||
today,
|
||||
seed_i64,
|
||||
goal_json
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(goal))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Application-level error type with automatic HTTP response conversion.
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors that can be returned by the server.
|
||||
///
|
||||
/// Each variant maps to a specific HTTP status code when converted to a
|
||||
/// response via [`IntoResponse`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
/// The request is missing a valid `Authorization: Bearer` header, or the
|
||||
/// JWT is expired / has an invalid signature.
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
/// The supplied credentials (username / password) were incorrect.
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
/// The requested username is already registered.
|
||||
#[error("username already taken")]
|
||||
UsernameTaken,
|
||||
|
||||
/// The client sent a malformed or invalid request body.
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
/// A database error occurred.
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
/// Password hashing failed.
|
||||
#[error("internal server error")]
|
||||
BcryptError(#[from] bcrypt::BcryptError),
|
||||
|
||||
/// JSON serialization / deserialization failed.
|
||||
#[error("serialization error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// A catch-all for unexpected internal failures.
|
||||
#[error("internal server error")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("database error: {e}");
|
||||
(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())
|
||||
}
|
||||
AppError::Json(e) => {
|
||||
tracing::error!("json error: {e}");
|
||||
(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())
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({ "error": message }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Leaderboard endpoints.
|
||||
//!
|
||||
//! `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 chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Body for `POST /api/leaderboard/opt-in`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OptInRequest {
|
||||
/// The display name the player wants shown on the leaderboard.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database row helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct LeaderboardRow {
|
||||
display_name: Option<String>,
|
||||
best_score: Option<i64>,
|
||||
best_time_secs: Option<i64>,
|
||||
recorded_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/leaderboard` — return all opted-in leaderboard entries.
|
||||
///
|
||||
/// Returns entries sorted by `best_score` descending (nulls last).
|
||||
pub async fn get_leaderboard(
|
||||
State(pool): State<SqlitePool>,
|
||||
_user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
|
||||
let rows = sqlx::query_as!(
|
||||
LeaderboardRow,
|
||||
r#"SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at
|
||||
FROM leaderboard l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE u.leaderboard_opt_in = 1
|
||||
ORDER BY
|
||||
CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,
|
||||
l.best_score DESC,
|
||||
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
|
||||
l.best_time_secs ASC"#
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
|
||||
.into_iter()
|
||||
.map(|r| -> Result<LeaderboardEntry, AppError> {
|
||||
let display_name = r
|
||||
.display_name
|
||||
.ok_or_else(|| AppError::Internal("missing display_name".into()))?;
|
||||
let recorded_at_str = r
|
||||
.recorded_at
|
||||
.ok_or_else(|| AppError::Internal("missing recorded_at".into()))?;
|
||||
let recorded_at = recorded_at_str
|
||||
.parse::<chrono::DateTime<Utc>>()
|
||||
.map_err(|e| AppError::Internal(format!("invalid recorded_at: {e}")))?;
|
||||
Ok(LeaderboardEntry {
|
||||
display_name,
|
||||
best_score: r.best_score.map(|v| v as i32),
|
||||
best_time_secs: r.best_time_secs.map(|v| v as u64),
|
||||
recorded_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(entries?))
|
||||
}
|
||||
|
||||
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
|
||||
///
|
||||
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
||||
/// leaderboard entry with the supplied display name.
|
||||
pub async fn opt_in(
|
||||
State(pool): State<SqlitePool>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<OptInRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.display_name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("display_name must not be empty".into()));
|
||||
}
|
||||
|
||||
// Mark the user as opted in.
|
||||
sqlx::query!(
|
||||
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
|
||||
user.user_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
// Upsert leaderboard row (preserve best_score / best_time if already present).
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO leaderboard (user_id, display_name, recorded_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
recorded_at = excluded.recorded_at"#,
|
||||
user.user_id,
|
||||
body.display_name,
|
||||
now
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Solitaire Quest sync server library.
|
||||
//!
|
||||
//! Exposes [`build_router`] so integration tests can construct the full Axum
|
||||
//! application against an in-memory SQLite database without starting a real
|
||||
//! TCP listener.
|
||||
|
||||
pub mod auth;
|
||||
pub mod challenge;
|
||||
pub mod error;
|
||||
pub mod leaderboard;
|
||||
pub mod middleware;
|
||||
pub mod sync;
|
||||
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware as axum_middleware,
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
||||
|
||||
/// Construct the full Axum [`Router`].
|
||||
///
|
||||
/// Separated from `main` so it can be instantiated in integration tests without
|
||||
/// starting a real TCP listener.
|
||||
pub fn build_router(pool: SqlitePool) -> Router {
|
||||
build_router_inner(pool, true)
|
||||
}
|
||||
|
||||
/// Construct the router without rate limiting.
|
||||
///
|
||||
/// Intended for integration tests only — do not use in production.
|
||||
#[doc(hidden)]
|
||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||
build_router_inner(pool, false)
|
||||
}
|
||||
|
||||
fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
||||
// Protected routes require a valid JWT (injected by require_auth middleware).
|
||||
let protected = Router::new()
|
||||
.route("/api/sync/pull", get(sync::pull))
|
||||
.route("/api/sync/push", post(sync::push))
|
||||
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||
.route("/api/account", delete(auth::delete_account))
|
||||
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||
|
||||
// Auth endpoints — rate-limited in production, unrestricted in tests.
|
||||
let auth_routes = Router::new()
|
||||
.route("/api/auth/register", post(auth::register))
|
||||
.route("/api/auth/login", post(auth::login))
|
||||
.route("/api/auth/refresh", post(auth::refresh));
|
||||
|
||||
let auth_routes = if rate_limit {
|
||||
// Rate limiter: 10 requests per minute per IP.
|
||||
// burst_size = 10, replenish every 6 seconds = 10/min steady-state.
|
||||
let governor_conf = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.per_second(6)
|
||||
.burst_size(10)
|
||||
.finish()
|
||||
.expect("invalid governor config"),
|
||||
);
|
||||
auth_routes.layer(GovernorLayer {
|
||||
config: governor_conf,
|
||||
})
|
||||
} else {
|
||||
auth_routes
|
||||
};
|
||||
|
||||
// Public endpoints (no auth, no rate limit beyond defaults).
|
||||
let public = Router::new()
|
||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||
.route("/health", get(health));
|
||||
|
||||
Router::new()
|
||||
.merge(protected)
|
||||
.merge(auth_routes)
|
||||
.merge(public)
|
||||
// Reject request bodies larger than 1 MB.
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||
.with_state(pool)
|
||||
}
|
||||
|
||||
/// `GET /health` — simple liveness probe, no auth required.
|
||||
async fn health() -> axum::Json<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
@@ -1,2 +1,61 @@
|
||||
// Full server implementation added in Phase 8C.
|
||||
fn main() {}
|
||||
//! Solitaire Quest sync server entry point.
|
||||
//!
|
||||
//! Reads configuration from environment variables (via `dotenvy`), initialises
|
||||
//! the SQLite database, runs migrations, then starts the Axum HTTP server.
|
||||
//!
|
||||
//! ## Required environment variables
|
||||
//!
|
||||
//! | Variable | Description |
|
||||
//! |----------------|---------------------------------------------------|
|
||||
//! | `DATABASE_URL` | SQLite connection string, e.g. `sqlite://sol.db` |
|
||||
//! | `JWT_SECRET` | HS256 signing secret (min 32 chars recommended) |
|
||||
//!
|
||||
//! ## Optional
|
||||
//!
|
||||
//! | Variable | Default | Description |
|
||||
//! |---------------|---------|-------------------------------|
|
||||
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
||||
|
||||
use solitaire_server::build_router;
|
||||
use sqlx::SqlitePool;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env file if present (silently ignored when absent).
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Initialise structured logging.
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let port: u16 = std::env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
.expect("SERVER_PORT must be a valid port number");
|
||||
|
||||
// Connect to SQLite and run pending migrations.
|
||||
let pool = SqlitePool::connect(&db_url)
|
||||
.await
|
||||
.expect("failed to connect to database");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("database migration failed");
|
||||
|
||||
tracing::info!("database ready at {db_url}");
|
||||
|
||||
let app = build_router(pool);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
tracing::info!("listening on {addr}");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("failed to bind TCP listener");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Axum middleware for JWT authentication.
|
||||
//!
|
||||
//! Extracts and validates the `Authorization: Bearer <token>` header, then
|
||||
//! injects the authenticated `user_id` into request extensions so handlers
|
||||
//! can access it via `Extension<AuthenticatedUser>`.
|
||||
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Request},
|
||||
http::request::Parts,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// The claims encoded in our JWT access tokens.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Subject — the user's UUID string.
|
||||
pub sub: String,
|
||||
/// Expiry timestamp (Unix seconds).
|
||||
pub exp: usize,
|
||||
/// Token kind: `"access"` or `"refresh"`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// The authenticated user identity injected into request extensions after
|
||||
/// successful JWT validation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
/// The authenticated user's UUID, as a string.
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// Axum middleware function that validates the Bearer JWT and injects
|
||||
/// [`AuthenticatedUser`] into request extensions.
|
||||
///
|
||||
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
|
||||
pub async fn require_auth(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
let token = extract_bearer_token(req.headers())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims = validate_access_token(&token, &secret)?;
|
||||
|
||||
req.extensions_mut().insert(AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
});
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Extract the raw token string from `Authorization: Bearer <token>`.
|
||||
fn extract_bearer_token(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers.get("Authorization")?.to_str().ok()?;
|
||||
let token = value.strip_prefix("Bearer ")?;
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
/// Decode and validate a JWT access token, returning its claims on success.
|
||||
pub fn validate_access_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<Claims>(token, &key, &validation)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
if data.claims.kind != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
/// Decode and validate a JWT refresh token, returning its claims on success.
|
||||
pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<Claims>(token, &key, &validation)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
if data.claims.kind != "refresh" {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axum extractor — allows handlers to receive AuthenticatedUser directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<AuthenticatedUser>()
|
||||
.cloned()
|
||||
.ok_or(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Sync pull and push handlers.
|
||||
//!
|
||||
//! `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 chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use solitaire_sync::{
|
||||
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
||||
};
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database row helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SyncRow {
|
||||
stats_json: Option<String>,
|
||||
achievements_json: Option<String>,
|
||||
progress_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Load the stored `SyncPayload` for `user_id` from the database.
|
||||
/// Returns `None` if this user has not pushed any data yet.
|
||||
async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRow>, AppError> {
|
||||
let row = sqlx::query_as!(
|
||||
SyncRow,
|
||||
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
|
||||
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()
|
||||
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
|
||||
|
||||
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
|
||||
let achievements: Vec<AchievementRecord> = serde_json::from_str(achievements_json)?;
|
||||
let progress: PlayerProgress = serde_json::from_str(progress_json)?;
|
||||
|
||||
Ok(SyncPayload {
|
||||
user_id: user_id
|
||||
.parse()
|
||||
.map_err(|_| AppError::Internal("stored user_id is not a valid UUID".into()))?,
|
||||
stats,
|
||||
achievements,
|
||||
progress,
|
||||
last_modified: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist a `SyncPayload` for `user_id` using an upsert.
|
||||
async fn store_payload(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
payload: &SyncPayload,
|
||||
) -> Result<(), AppError> {
|
||||
let stats_json = serde_json::to_string(&payload.stats)?;
|
||||
let achievements_json = serde_json::to_string(&payload.achievements)?;
|
||||
let progress_json = serde_json::to_string(&payload.progress)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO sync_state (user_id, stats_json, achievements_json, progress_json, last_modified)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
stats_json = excluded.stats_json,
|
||||
achievements_json = excluded.achievements_json,
|
||||
progress_json = excluded.progress_json,
|
||||
last_modified = excluded.last_modified"#,
|
||||
user_id,
|
||||
stats_json,
|
||||
achievements_json,
|
||||
progress_json,
|
||||
now
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/sync/pull` — return the server's stored payload for this user.
|
||||
///
|
||||
/// If the user has never pushed any data, returns a default payload.
|
||||
pub async fn pull(
|
||||
State(pool): State<SqlitePool>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<SyncResponse>, AppError> {
|
||||
let stored_payload = match load_sync_row(&pool, &user.user_id).await? {
|
||||
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||
None => {
|
||||
// First pull — no server data yet; return an empty default payload.
|
||||
let uid = user
|
||||
.user_id
|
||||
.parse()
|
||||
.map_err(|_| AppError::Internal("invalid user_id UUID".into()))?;
|
||||
SyncPayload {
|
||||
user_id: uid,
|
||||
stats: StatsSnapshot::default(),
|
||||
achievements: vec![],
|
||||
progress: PlayerProgress::default(),
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(SyncResponse {
|
||||
merged: stored_payload,
|
||||
server_time: Utc::now(),
|
||||
conflicts: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/sync/push` — merge the client's payload with the server's
|
||||
/// stored payload, persist the result, and return it.
|
||||
pub async fn push(
|
||||
State(pool): State<SqlitePool>,
|
||||
user: AuthenticatedUser,
|
||||
Json(client_payload): Json<SyncPayload>,
|
||||
) -> Result<Json<SyncResponse>, AppError> {
|
||||
// Reject payloads that claim to belong to a different user.
|
||||
if client_payload.user_id.to_string() != user.user_id {
|
||||
return Err(AppError::BadRequest("user_id mismatch".into()));
|
||||
}
|
||||
|
||||
let server_payload = match load_sync_row(&pool, &user.user_id).await? {
|
||||
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||
None => {
|
||||
// First push — nothing to merge against; store directly.
|
||||
store_payload(&pool, &user.user_id, &client_payload).await?;
|
||||
return Ok(Json(SyncResponse {
|
||||
merged: client_payload,
|
||||
server_time: Utc::now(),
|
||||
conflicts: vec![],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||
|
||||
store_payload(&pool, &user.user_id, &merged).await?;
|
||||
|
||||
Ok(Json(SyncResponse {
|
||||
merged,
|
||||
server_time: Utc::now(),
|
||||
conflicts,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
//! Integration tests for `solitaire_server`.
|
||||
//!
|
||||
//! Every test uses an in-memory SQLite database and [`build_test_router`]
|
||||
//! (rate limiting disabled) — no real TCP listener is started. Requests are dispatched via
|
||||
//! [`tower::ServiceExt::oneshot`].
|
||||
//!
|
||||
//! # JWT secret
|
||||
//!
|
||||
//! Each test calls [`set_jwt_secret`] before touching any endpoint that reads
|
||||
//! `JWT_SECRET` from the environment. This is safe because `cargo test` runs
|
||||
//! integration-test binaries single-threaded by default.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
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 tower::ServiceExt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The JWT secret injected into the environment for all tests.
|
||||
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test infrastructure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create an in-memory SQLite pool and run all pending migrations.
|
||||
///
|
||||
/// `max_connections(1)` is required for SQLite in-memory databases: each
|
||||
/// connection to `sqlite::memory:` is a *separate* database, so if the pool
|
||||
/// opens a second connection the handler sees an empty schema and fails.
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("failed to connect to in-memory SQLite database");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("failed to run database migrations");
|
||||
pool
|
||||
}
|
||||
|
||||
/// Inject `JWT_SECRET` into the process environment so all auth code can read it.
|
||||
///
|
||||
/// # Safety
|
||||
/// Only called from test code where tests run sequentially in a single binary.
|
||||
fn set_jwt_secret() {
|
||||
// SAFETY: test-only; integration test binaries are single-threaded.
|
||||
unsafe { std::env::set_var("JWT_SECRET", TEST_SECRET) };
|
||||
}
|
||||
|
||||
/// Fake client IP injected by all test requests so `tower_governor`'s
|
||||
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
|
||||
const TEST_CLIENT_IP: &str = "127.0.0.1";
|
||||
|
||||
/// Send a `POST` request with a JSON body and return the raw response.
|
||||
async fn post_json(app: axum::Router, path: &str, body: Value) -> Response {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("failed to serialise request body"),
|
||||
))
|
||||
.expect("failed to build POST request");
|
||||
app.oneshot(req).await.expect("oneshot failed")
|
||||
}
|
||||
|
||||
/// Send an authenticated `GET` request and return the raw response.
|
||||
async fn get_authed(app: axum::Router, path: &str, token: &str) -> Response {
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::empty())
|
||||
.expect("failed to build GET request");
|
||||
app.oneshot(req).await.expect("oneshot failed")
|
||||
}
|
||||
|
||||
/// Send an authenticated `POST` request with a JSON body and return the raw response.
|
||||
async fn post_authed(app: axum::Router, path: &str, token: &str, body: Value) -> Response {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("failed to serialise request body"),
|
||||
))
|
||||
.expect("failed to build authenticated POST request");
|
||||
app.oneshot(req).await.expect("oneshot failed")
|
||||
}
|
||||
|
||||
/// Send an authenticated `DELETE` request and return the raw response.
|
||||
async fn delete_authed(app: axum::Router, path: &str, token: &str) -> Response {
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(path)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::empty())
|
||||
.expect("failed to build DELETE request");
|
||||
app.oneshot(req).await.expect("oneshot failed")
|
||||
}
|
||||
|
||||
/// Collect the response body bytes and deserialise them as JSON.
|
||||
async fn body_json(resp: Response) -> Value {
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("failed to read response body");
|
||||
serde_json::from_slice(&bytes).expect("response body is not valid JSON")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT helpers (test-side only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimal JWT claims used only for decoding in test assertions.
|
||||
#[derive(Deserialize)]
|
||||
struct TestClaims {
|
||||
sub: String,
|
||||
}
|
||||
|
||||
/// Decode an access token and return the `sub` (user UUID) claim.
|
||||
///
|
||||
/// Uses `validate_exp = false` so tests never fail due to clock skew between
|
||||
/// token issuance and assertion.
|
||||
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");
|
||||
data.claims.sub
|
||||
}
|
||||
|
||||
/// Register a new user and return `(access_token, refresh_token)`.
|
||||
async fn register_user(app: axum::Router, username: &str, password: &str) -> (String, String) {
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/register",
|
||||
serde_json::json!({ "username": username, "password": password }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"register should return 200"
|
||||
);
|
||||
let body = body_json(resp).await;
|
||||
let access = body["access_token"]
|
||||
.as_str()
|
||||
.expect("access_token missing from register response")
|
||||
.to_string();
|
||||
let refresh = body["refresh_token"]
|
||||
.as_str()
|
||||
.expect("refresh_token missing from register response")
|
||||
.to_string();
|
||||
(access, refresh)
|
||||
}
|
||||
|
||||
/// Build a [`SyncPayload`] for `user_id_str` with `games_played` set to the
|
||||
/// given value and all other fields set to defaults.
|
||||
fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
|
||||
SyncPayload {
|
||||
user_id: uuid::Uuid::parse_str(user_id_str)
|
||||
.expect("user_id_str from JWT sub must be a valid UUID"),
|
||||
stats: StatsSnapshot {
|
||||
games_played,
|
||||
games_won: 3,
|
||||
..StatsSnapshot::default()
|
||||
},
|
||||
achievements: vec![],
|
||||
progress: PlayerProgress::default(),
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth flow tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/auth/register` must return 200 with both tokens.
|
||||
#[tokio::test]
|
||||
async fn register_creates_account_and_returns_tokens() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/register",
|
||||
serde_json::json!({ "username": "alice", "password": "hunter2" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Registering the same username twice must return 409 Conflict on the second attempt.
|
||||
#[tokio::test]
|
||||
async fn register_duplicate_username_returns_conflict() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
let creds = serde_json::json!({ "username": "bob", "password": "secret" });
|
||||
|
||||
// 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");
|
||||
|
||||
// Second registration with the same username is rejected.
|
||||
let second = post_json(app, "/api/auth/register", creds).await;
|
||||
assert_eq!(
|
||||
second.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"duplicate username must return 409"
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
||||
#[tokio::test]
|
||||
async fn login_with_correct_credentials_returns_tokens() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// Register first.
|
||||
let _ = register_user(app.clone(), "charlie", "p4ssw0rd").await;
|
||||
|
||||
// Then login.
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/login",
|
||||
serde_json::json!({ "username": "charlie", "password": "p4ssw0rd" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login` with a wrong password must return 401.
|
||||
#[tokio::test]
|
||||
async fn login_with_wrong_password_returns_401() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// Register a user.
|
||||
let _ = register_user(app.clone(), "dave", "correct_horse").await;
|
||||
|
||||
// Attempt to log in with the wrong password.
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/login",
|
||||
serde_json::json!({ "username": "dave", "password": "wrong_password" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"wrong password must return 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() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/login",
|
||||
serde_json::json!({ "username": "nobody", "password": "whatever" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unknown username must return 401"
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
|
||||
#[tokio::test]
|
||||
async fn refresh_returns_new_access_token() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;
|
||||
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": refresh }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert!(
|
||||
body["access_token"].is_string(),
|
||||
"refresh must return a new access_token"
|
||||
);
|
||||
}
|
||||
|
||||
/// Supplying an access token to `POST /api/auth/refresh` must be rejected because
|
||||
/// the `kind` claim will be `"access"`, not `"refresh"`.
|
||||
#[tokio::test]
|
||||
async fn refresh_with_access_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;
|
||||
|
||||
// Send the access token as if it were a refresh token.
|
||||
let resp = post_json(
|
||||
app,
|
||||
"/api/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": access }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"using an access token as a refresh token must return 401"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync roundtrip tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Push a payload, then pull — the pulled data must reflect the pushed values.
|
||||
#[tokio::test]
|
||||
async fn push_then_pull_returns_pushed_data() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
|
||||
let user_id = decode_sub(&access);
|
||||
|
||||
let payload = make_payload(&user_id, 7);
|
||||
|
||||
// Push the payload to the server.
|
||||
let push_resp = post_authed(
|
||||
app.clone(),
|
||||
"/api/sync/push",
|
||||
&access,
|
||||
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");
|
||||
|
||||
// Pull and verify the stats were persisted.
|
||||
let pull_resp = get_authed(app, "/api/sync/pull", &access).await;
|
||||
assert_eq!(pull_resp.status(), StatusCode::OK, "pull must return 200");
|
||||
|
||||
let pull_body = body_json(pull_resp).await;
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;
|
||||
|
||||
// Build a payload with a random UUID that won't match the JWT sub.
|
||||
let wrong_uuid = uuid::Uuid::new_v4();
|
||||
let payload = SyncPayload {
|
||||
user_id: wrong_uuid,
|
||||
stats: StatsSnapshot::default(),
|
||||
achievements: vec![],
|
||||
progress: PlayerProgress::default(),
|
||||
last_modified: Utc::now(),
|
||||
};
|
||||
|
||||
let resp = post_authed(
|
||||
app,
|
||||
"/api/sync/push",
|
||||
&access,
|
||||
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"mismatched user_id must return 400"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pull before any push returns a default empty payload (200, not 404).
|
||||
#[tokio::test]
|
||||
async fn pull_before_push_returns_default_payload() {
|
||||
set_jwt_secret();
|
||||
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");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// Accessing `/api/sync/pull` without a token must return 401.
|
||||
#[tokio::test]
|
||||
async fn pull_without_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/sync/pull")
|
||||
.body(Body::empty())
|
||||
.expect("failed to build unauthenticated GET request");
|
||||
|
||||
let resp = app.oneshot(req).await.expect("oneshot failed");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing token must return 401"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account deletion tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// After `DELETE /api/account`, the user row (and sync data via CASCADE) is gone.
|
||||
/// A subsequent pull attempt should fail — either 401 (JWT rejected before DB
|
||||
/// lookup) or the row is simply absent. Either way, the deletion itself must
|
||||
/// return 200.
|
||||
#[tokio::test]
|
||||
async fn delete_account_succeeds_and_data_is_gone() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
|
||||
let user_id = decode_sub(&access);
|
||||
|
||||
// First push some data.
|
||||
let payload = make_payload(&user_id, 5);
|
||||
let push_resp = post_authed(
|
||||
app.clone(),
|
||||
"/api/sync/push",
|
||||
&access,
|
||||
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
|
||||
)
|
||||
.await;
|
||||
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;
|
||||
assert_eq!(
|
||||
del_resp.status(),
|
||||
StatusCode::OK,
|
||||
"DELETE /api/account must return 200"
|
||||
);
|
||||
let del_body = body_json(del_resp).await;
|
||||
assert_eq!(
|
||||
del_body["ok"], true,
|
||||
"delete response must contain ok: true"
|
||||
);
|
||||
|
||||
// Subsequent pull with the same token: the JWT is still cryptographically
|
||||
// valid (the server has no token revocation list), but the user row no
|
||||
// longer exists in the database. The pull handler will return a default
|
||||
// empty payload rather than a 404. The important assertion is that delete
|
||||
// returned 200 above; we just confirm the server doesn't panic.
|
||||
let pull_resp = get_authed(app, "/api/sync/pull", &access).await;
|
||||
// 200 (default payload) or 404/500 depending on implementation; we only
|
||||
// assert that the server responds at all (no panic / connection drop).
|
||||
let status = pull_resp.status();
|
||||
assert!(
|
||||
status.is_success() || status.is_client_error() || status.is_server_error(),
|
||||
"server must respond after account deletion"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health endpoint tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /health` must return 200 with `status: "ok"` — no auth required.
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
// No JWT needed; set it anyway for consistency.
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.expect("failed to build health request");
|
||||
|
||||
let resp = app.oneshot(req).await.expect("oneshot failed");
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily challenge tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
|
||||
#[tokio::test]
|
||||
async fn daily_challenge_returns_goal_for_today() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/daily-challenge")
|
||||
.body(Body::empty())
|
||||
.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");
|
||||
|
||||
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["description"].is_string(),
|
||||
"challenge must include a description"
|
||||
);
|
||||
}
|
||||
|
||||
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
|
||||
#[tokio::test]
|
||||
async fn daily_challenge_is_deterministic() {
|
||||
set_jwt_secret();
|
||||
// Use the same pool so the second call hits the stored row.
|
||||
let pool = test_pool().await;
|
||||
|
||||
let make_req = || {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/daily-challenge")
|
||||
.body(Body::empty())
|
||||
.expect("failed to build daily-challenge request")
|
||||
};
|
||||
|
||||
let resp1 = build_test_router(pool.clone())
|
||||
.oneshot(make_req())
|
||||
.await
|
||||
.expect("first oneshot failed");
|
||||
assert_eq!(resp1.status(), StatusCode::OK);
|
||||
let body1 = body_json(resp1).await;
|
||||
|
||||
let resp2 = build_test_router(pool)
|
||||
.oneshot(make_req())
|
||||
.await
|
||||
.expect("second oneshot failed");
|
||||
assert_eq!(resp2.status(), StatusCode::OK);
|
||||
let body2 = body_json(resp2).await;
|
||||
|
||||
assert_eq!(
|
||||
body1["seed"], body2["seed"],
|
||||
"two calls must return the same seed"
|
||||
);
|
||||
assert_eq!(
|
||||
body1["date"], body2["date"],
|
||||
"two calls must return the same date"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaderboard tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
||||
#[tokio::test]
|
||||
async fn leaderboard_without_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/leaderboard")
|
||||
.body(Body::empty())
|
||||
.expect("failed to build leaderboard request");
|
||||
|
||||
let resp = app.oneshot(req).await.expect("oneshot failed");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"leaderboard without auth must return 401"
|
||||
);
|
||||
}
|
||||
|
||||
/// Opting in and then fetching the leaderboard returns the opted-in entry.
|
||||
#[tokio::test]
|
||||
async fn opt_in_then_leaderboard_shows_entry() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;
|
||||
|
||||
// Opt in with a display name.
|
||||
let opt_resp = post_authed(
|
||||
app.clone(),
|
||||
"/api/leaderboard/opt-in",
|
||||
&access,
|
||||
serde_json::json!({ "display_name": "KarenTheGreat" }),
|
||||
)
|
||||
.await;
|
||||
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");
|
||||
|
||||
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");
|
||||
assert!(found, "opted-in user must appear in leaderboard");
|
||||
}
|
||||
Reference in New Issue
Block a user