Compare commits

..

4 Commits

Author SHA1 Message Date
funman300 08f74d1e25 docs(handoff): mark E/F/G complete; update HEAD + origin state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:55:30 -07:00
funman300 6e6f3ef1ff feat(server): per-user rate limiting on protected sync endpoints
Adds a UserIdKeyExtractor that decodes the Authorization JWT to rate-limit
each user individually (falls back to client IP for unauthenticated
requests). Protected routes now throttle at 10-request burst / 1 token
per 10 s steady-state (6/min), matching the surface attack area of the
1 MB sync/push endpoint.

Also adds an integration test: sync_push_rate_limit_returns_429_on_11th_request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:55:07 -07:00
funman300 549a817bb1 refactor(sync): remove mirror_achievement from SyncProvider trait
The method had a no-op default, was never overridden in
SolitaireServerClient, and was never called by any engine system.
Achievements are already synced via the full SyncPayload push, so
the method provided no additional value and was a dead maintenance trap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:49:36 -07:00
funman300 613bbf8799 feat(settings): add theme import scan button
Adds "Scan for new themes" button to the Settings Appearance section.
The button fires ScanThemesRequestEvent, handled by a separate
handle_scan_themes system that walks user_theme_dir() for unrecognised
.zip archives, calls import_theme() on each, refreshes ThemeRegistry,
and fires InfoToastEvent messages reporting per-file results.

The import path (label) is shown above the button so players know where
to drop theme archives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:46:35 -07:00
6 changed files with 331 additions and 31 deletions
+17 -21
View File
@@ -1,7 +1,6 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-12 — ARCHITECTURE.md updated to v1.3 (all 8 Phase 8 gaps closed); **Last updated:** 2026-05-12 — Sync rate limiting + mirror_achievement removal + theme import scan shipped (`6e6f3ef`). HEAD locally: `6e6f3ef`. Push pending.
`SESSION_HANDOFF.md` updated. Push pending.
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
modal, re-auth on token expiry, account deletion flow, server deployment modal, re-auth on token expiry, account deletion flow, server deployment
@@ -13,9 +12,9 @@ and full server integration tests.
## Current state ## Current state
- **HEAD locally:** `bd388fe` (docs: CHANGELOG Phase 8 entry). - **HEAD locally:** `6e6f3ef` (feat: sync rate limiting).
- **HEAD on origin:** `272d31f` (feat: account deletion — last pushed commit). - **HEAD on origin:** `b129664` (pushed — 4 commits ahead).
- **Working tree:** `ARCHITECTURE.md` + `SESSION_HANDOFF.md` modified, uncommitted. - **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean. - **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1300+ passing / 0 failing** across the workspace. - **Tests:** **1300+ passing / 0 failing** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.22.0`. - **Tags on origin:** `v0.9.0` through `v0.22.0`.
@@ -61,12 +60,12 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
want a different public identity. want a different public identity.
### 3. Security hardening ### 3. Security hardening
- **Refresh token rotation.** `POST /api/auth/refresh` returns only a new - [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
access token; the refresh token never rotates. Standard mitigation: issue a (migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
new refresh token on each call and invalidate the old one (needs a tests.
`last_refresh_token` column or a separate table). - [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
- **Sync endpoint rate limiting.** Only `/api/auth/*` has `tower-governor`; decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
`/api/sync/push` (1 MB body) has no per-user throttle. steady-state; integration test passes.
### 4. Android validation ### 4. Android validation
- **Android Keystore functional test** — JNI AES-GCM code ships (`f281425`) but - **Android Keystore functional test** — JNI AES-GCM code ships (`f281425`) but
@@ -78,13 +77,12 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
APK but pollutes CI output. Document `--lib` as canonical or upstream a fix. APK but pollutes CI output. Document `--lib` as canonical or upstream a fix.
### 5. Feature completeness ### 5. Feature completeness
- **Theme importer UI.** `import_theme()` (Phase 7, `theme/importer.rs`) is - [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
complete but has no Settings button trigger. Players must copy theme files Settings Appearance section. Shows import path label, scans user_theme_dir()
manually. for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
- **`mirror_achievement` decision.** `SyncProvider` has this method with a - [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
no-op default; `SolitaireServerClient` never overrides it, no server endpoint default never overridden and never called; achievements already sync via
exists. Either implement (`POST /api/achievements/mirror` + client call on `SyncPayload` push. Deleted from trait and blanket impl.
`AchievementUnlockedEvent`) or delete from the trait.
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git. - **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build` Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
invocation to regenerate it. invocation to regenerate it.
@@ -151,12 +149,10 @@ READ FIRST (in order):
7. ~/.claude/projects/<this-project>/memory/MEMORY.md 7. ~/.claude/projects/<this-project>/memory/MEMORY.md
OPEN WORK (in priority order): OPEN WORK (in priority order):
B. Leaderboard best-score auto-post (server sync handler + optional
GameWonEvent path in sync_plugin)
C. Refresh token rotation (server auth handler + new column/table)
D. Android AVD functional tests (Keystore + clipboard) D. Android AVD functional tests (Keystore + clipboard)
E. Theme importer UI button in Settings E. Theme importer UI button in Settings
F. mirror_achievement: decide + implement or remove from trait F. mirror_achievement: decide + implement or remove from trait
G. Sync endpoint rate limiting (POST /api/sync/push has no per-user throttle)
Ask which to start. All are independent; any is a valid next arc. Ask which to start. All are independent; any is a valid next arc.
``` ```
-7
View File
@@ -27,10 +27,6 @@ pub trait SyncProvider: Send + Sync {
fn backend_name(&self) -> &'static str; fn backend_name(&self) -> &'static str;
/// Returns true if the user is currently authenticated with this backend. /// Returns true if the user is currently authenticated with this backend.
fn is_authenticated(&self) -> bool; fn is_authenticated(&self) -> bool;
/// Mirror an achievement unlock to this backend (no-op for most backends).
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
Ok(())
}
/// Fetch the global leaderboard from this backend. Returns an empty list /// Fetch the global leaderboard from this backend. Returns an empty list
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`). /// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> { async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
@@ -83,9 +79,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
fn is_authenticated(&self) -> bool { fn is_authenticated(&self) -> bool {
(**self).is_authenticated() (**self).is_authenticated()
} }
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
(**self).mirror_achievement(id).await
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> { async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
(**self).fetch_leaderboard().await (**self).fetch_leaderboard().await
} }
+9
View File
@@ -283,6 +283,15 @@ pub struct ForfeitEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitRequestEvent; pub struct ForfeitRequestEvent;
/// Fired when the player clicks "Scan for new themes" in Settings.
///
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
/// fires [`InfoToastEvent`] messages to report results.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ScanThemesRequestEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID /// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting. /// and destination pile for visual highlighting.
/// ///
+176 -1
View File
@@ -31,7 +31,8 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair}; use crate::assets::user_theme_dir;
use crate::theme::{import_theme, ImportError, ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
@@ -235,6 +236,8 @@ enum SettingsButton {
/// flag only affects launches without saved geometry — the /// flag only affects launches without saved geometry — the
/// player's last window size always wins. /// player's last window size always wins.
ToggleSmartDefaultSize, ToggleSmartDefaultSize,
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
ScanThemes,
SyncNow, SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local). /// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync, ConnectSync,
@@ -293,6 +296,7 @@ impl SettingsButton {
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80, SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85, SettingsButton::SelectTheme(_) => 85,
SettingsButton::ScanThemes => 86,
// Sync section // Sync section
SettingsButton::SyncNow => 90, SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91, SettingsButton::ConnectSync => 91,
@@ -377,6 +381,7 @@ impl Plugin for SettingsPlugin {
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons, handle_sync_buttons,
handle_scan_themes,
update_sync_status_text, update_sync_status_text,
update_card_back_text, update_card_back_text,
update_background_text, update_background_text,
@@ -1070,6 +1075,9 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
} }
SettingsButton::ScanThemes => {
// Handled by `handle_scan_themes`.
}
SettingsButton::SyncNow SettingsButton::SyncNow
| SettingsButton::ConnectSync | SettingsButton::ConnectSync
| SettingsButton::DisconnectSync | SettingsButton::DisconnectSync
@@ -1637,6 +1645,7 @@ fn spawn_settings_panel(
font_res, font_res,
); );
} }
import_themes_row(body, font_res);
// --- Sync --- // --- Sync ---
section_label(body, "Sync", font_res); section_label(body, "Sync", font_res);
@@ -2394,6 +2403,172 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every /// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// Settings icon button ships with one because the glyph alone (`+`, ``, /// Settings icon button ships with one because the glyph alone (`+`, ``,
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning. /// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
/// Scans `user_theme_dir()` for `.zip` files and calls [`import_theme`] on
/// each one. On success, [`ThemeRegistry`] is refreshed in place and an
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
/// already installed) are silently skipped; all other errors produce a warning
/// toast. A final toast tells the player to reopen Settings to see new themes.
fn handle_scan_themes(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut toast: MessageWriter<InfoToastEvent>,
mut registry: Option<ResMut<crate::theme::ThemeRegistry>>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
if !matches!(button, SettingsButton::ScanThemes) {
continue;
}
let themes_dir = user_theme_dir();
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "zip"))
.collect(),
Err(_) => {
toast.write(InfoToastEvent(
"Themes folder not found — drop .zip files there first.".to_string(),
));
return;
}
};
if zips.is_empty() {
toast.write(InfoToastEvent(
"No .zip files found in themes folder.".to_string(),
));
return;
}
let mut imported = 0u32;
let mut errors = 0u32;
for zip_path in &zips {
match import_theme(zip_path) {
Ok(theme_id) => {
toast.write(InfoToastEvent(format!(
"Imported theme '{}'.",
theme_id.as_str()
)));
imported += 1;
}
Err(ImportError::IdCollision { .. }) => {
// Already installed — silent skip.
}
Err(e) => {
let name = zip_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
toast.write(InfoToastEvent(format!("Import failed ({name}): {e}")));
errors += 1;
}
}
}
if imported == 0 && errors == 0 {
toast.write(InfoToastEvent("All themes already installed.".to_string()));
return;
}
if imported > 0 {
if let Some(reg) = &mut registry {
refresh_registry(reg, &themes_dir);
}
toast.write(InfoToastEvent(
"Reopen Settings to see new themes in the picker.".to_string(),
));
}
}
}
/// A small pill-shaped settings button, matching the style used in `sync_row`.
fn pill_button(
parent: &mut ChildSpawnerCommands,
marker: SettingsButton,
label: &str,
tooltip: &'static str,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
marker,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
});
}
/// "Import Theme" row: folder-path label + "Scan for new themes" button.
///
/// The player drops `.zip` theme archives into the themes folder shown here,
/// then presses the button. [`handle_scan_themes`] picks them up, validates,
/// and installs them. Reopen Settings to see newly imported themes in the
/// card-theme picker.
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
},
))
.with_children(|col| {
// Folder path hint.
let path_str = user_theme_dir().to_string_lossy().into_owned();
col.spawn((
Text::new(format!("Drop .zip files into: {path_str}")),
caption_font,
TextColor(TEXT_SECONDARY),
));
// Scan button.
col.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..default()
})
.with_children(|row| {
pill_button(
row,
SettingsButton::ScanThemes,
"Scan for new themes",
"Scan the themes folder for .zip archives and install any that are new.",
font_res,
);
});
});
}
fn icon_button( fn icon_button(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
label: &str, label: &str,
+67 -2
View File
@@ -19,15 +19,61 @@ use axum::{
routing::{delete, get, post}, routing::{delete, get, post},
Router, Router,
}; };
use jsonwebtoken::{decode, DecodingKey, Validation};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::sync::Arc;
use tower_governor::{ use tower_governor::{
errors::GovernorError,
governor::GovernorConfigBuilder, governor::GovernorConfigBuilder,
key_extractor::SmartIpKeyExtractor, key_extractor::{KeyExtractor, SmartIpKeyExtractor},
GovernorLayer, GovernorLayer,
}; };
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
/// Rate-limiting key extractor for authenticated endpoints.
///
/// Extracts the authenticated user's UUID from the `Authorization: Bearer` JWT
/// so each user gets their own bucket. Falls back to the client IP address when
/// the header is absent or the token fails signature verification — this
/// protects the server from unauthenticated request floods while ensuring
/// legitimate users are always identified by identity rather than IP.
///
/// Expiry is intentionally **not** checked here: `require_auth` validates the
/// full token (including `exp`) and returns 401. Counting an expired token
/// against the user's bucket is harmless and avoids returning 500 (the
/// `UnableToExtractKey` outcome) for a request that would get 401 anyway.
#[derive(Clone)]
struct UserIdKeyExtractor {
jwt_secret: String,
}
impl KeyExtractor for UserIdKeyExtractor {
type Key = String;
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
if let Some(user_id) = self.try_extract_user_id(req.headers()) {
return Ok(user_id);
}
// Fall back to IP so unauthenticated bursts don't bypass throttling.
SmartIpKeyExtractor
.extract(req)
.map(|ip| ip.to_string())
}
}
impl UserIdKeyExtractor {
fn try_extract_user_id(&self, headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers.get("Authorization")?.to_str().ok()?;
let token = value.strip_prefix("Bearer ")?;
let key = DecodingKey::from_secret(self.jwt_secret.as_bytes());
let mut validation = Validation::default();
validation.validate_exp = false;
decode::<middleware::Claims>(token, &key, &validation)
.ok()
.map(|d| d.claims.sub)
}
}
/// Shared application state injected into every Axum handler via [`axum::extract::State`]. /// Shared application state injected into every Axum handler via [`axum::extract::State`].
/// ///
/// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup /// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup
@@ -64,7 +110,7 @@ pub fn build_test_router(pool: SqlitePool) -> Router {
fn build_router_inner(state: AppState, rate_limit: bool) -> Router { fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
// Protected routes require a valid JWT (injected by require_auth middleware). // Protected routes require a valid JWT (injected by require_auth middleware).
let protected = Router::new() let protected_base = Router::new()
.route("/api/sync/pull", get(sync::pull)) .route("/api/sync/pull", get(sync::pull))
.route("/api/sync/push", post(sync::push)) .route("/api/sync/push", post(sync::push))
.route("/api/replays", post(replays::upload)) .route("/api/replays", post(replays::upload))
@@ -77,6 +123,25 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
middleware::require_auth, middleware::require_auth,
)); ));
// Per-user rate limit on protected endpoints: 10-request burst, then 1
// token replenished every 10 seconds (6/min steady-state). This prevents
// a single compromised account from hammering the 1 MB sync/push endpoint.
let protected = if rate_limit {
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(UserIdKeyExtractor {
jwt_secret: state.jwt_secret.clone(),
})
.per_second(10)
.burst_size(10)
.finish()
.expect("invalid sync governor config"),
);
protected_base.layer(GovernorLayer::new(governor_conf))
} else {
protected_base
};
// Auth endpoints — rate-limited in production, unrestricted in tests. // Auth endpoints — rate-limited in production, unrestricted in tests.
let auth_routes = Router::new() let auth_routes = Router::new()
.route("/api/auth/register", post(auth::register)) .route("/api/auth/register", post(auth::register))
+62
View File
@@ -1523,6 +1523,68 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
); );
} }
/// The 11th `POST /api/sync/push` from the same authenticated user within the
/// rate-limit window must return 429 Too Many Requests.
///
/// Uses [`solitaire_server::build_router`] (rate limiting ON) so the
/// GovernorLayer is applied. We register a fresh account, then send 10 pushes
/// (consuming the burst allowance), and assert the 11th is throttled.
///
/// Note: the push body deliberately omits valid `SyncPayload` structure —
/// that would return 422, but the rate limiter fires before deserialization,
/// so the response code for the first 10 is 422 and for the 11th is 429.
/// The test only asserts `!= 429` for requests 110 and `== 429` for request 11.
#[tokio::test]
async fn sync_push_rate_limit_returns_429_on_11th_request() {
let state = solitaire_server::AppState {
pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(),
};
let app = solitaire_server::build_router(state);
// Register a user to obtain a valid JWT for the UserIdKeyExtractor.
let (token, _) = register_user(app.clone(), "sync_ratelimit_user", "p4ssword!").await;
let stub_body = serde_json::to_vec(&serde_json::json!({})).unwrap();
// First 10 requests consume the burst allowance (burst_size = 10).
// The body is intentionally invalid — the rate limiter fires before
// deserialization, so we get 422 rather than 200. We only assert != 429.
for i in 0..10 {
let req = Request::builder()
.method("POST")
.uri("/api/sync/push")
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {token}"))
.header("x-forwarded-for", TEST_CLIENT_IP)
.body(Body::from(stub_body.clone()))
.expect("failed to build request");
let resp = app.clone().oneshot(req).await.expect("oneshot failed");
assert_ne!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"request {} of 10 must not be rate-limited",
i + 1
);
}
// The 11th request must be throttled.
let req = Request::builder()
.method("POST")
.uri("/api/sync/push")
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {token}"))
.header("x-forwarded-for", TEST_CLIENT_IP)
.body(Body::from(stub_body))
.expect("failed to build 11th request");
let resp = app.clone().oneshot(req).await.expect("oneshot failed");
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"11th sync push must be rate-limited with 429"
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Replay endpoints // Replay endpoints
// //