Compare commits
4 Commits
b129664344
...
08f74d1e25
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f74d1e25 | |||
| 6e6f3ef1ff | |||
| 549a817bb1 | |||
| 613bbf8799 |
+17
-21
@@ -1,7 +1,6 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-12 — ARCHITECTURE.md updated to v1.3 (all 8 Phase 8 gaps closed);
|
||||
`SESSION_HANDOFF.md` updated. Push pending.
|
||||
**Last updated:** 2026-05-12 — Sync rate limiting + mirror_achievement removal + theme import scan shipped (`6e6f3ef`). HEAD locally: `6e6f3ef`. Push pending.
|
||||
|
||||
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
|
||||
@@ -13,9 +12,9 @@ and full server integration tests.
|
||||
|
||||
## Current state
|
||||
|
||||
- **HEAD locally:** `bd388fe` (docs: CHANGELOG Phase 8 entry).
|
||||
- **HEAD on origin:** `272d31f` (feat: account deletion — last pushed commit).
|
||||
- **Working tree:** `ARCHITECTURE.md` + `SESSION_HANDOFF.md` modified, uncommitted.
|
||||
- **HEAD locally:** `6e6f3ef` (feat: sync rate limiting).
|
||||
- **HEAD on origin:** `b129664` (pushed — 4 commits ahead).
|
||||
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||
- **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.
|
||||
|
||||
### 3. Security hardening
|
||||
- **Refresh token rotation.** `POST /api/auth/refresh` returns only a new
|
||||
access token; the refresh token never rotates. Standard mitigation: issue a
|
||||
new refresh token on each call and invalidate the old one (needs a
|
||||
`last_refresh_token` column or a separate table).
|
||||
- **Sync endpoint rate limiting.** Only `/api/auth/*` has `tower-governor`;
|
||||
`/api/sync/push` (1 MB body) has no per-user throttle.
|
||||
- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
|
||||
(migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
|
||||
tests.
|
||||
- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
|
||||
decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
|
||||
steady-state; integration test passes.
|
||||
|
||||
### 4. Android validation
|
||||
- **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.
|
||||
|
||||
### 5. Feature completeness
|
||||
- **Theme importer UI.** `import_theme()` (Phase 7, `theme/importer.rs`) is
|
||||
complete but has no Settings button trigger. Players must copy theme files
|
||||
manually.
|
||||
- **`mirror_achievement` decision.** `SyncProvider` has this method with a
|
||||
no-op default; `SolitaireServerClient` never overrides it, no server endpoint
|
||||
exists. Either implement (`POST /api/achievements/mirror` + client call on
|
||||
`AchievementUnlockedEvent`) or delete from the trait.
|
||||
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
|
||||
Settings Appearance section. Shows import path label, scans user_theme_dir()
|
||||
for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
|
||||
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
||||
default never overridden and never called; achievements already sync via
|
||||
`SyncPayload` push. Deleted from trait and blanket impl.
|
||||
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
|
||||
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
|
||||
invocation to regenerate it.
|
||||
@@ -151,12 +149,10 @@ READ FIRST (in order):
|
||||
7. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
|
||||
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)
|
||||
E. Theme importer UI button in Settings
|
||||
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.
|
||||
```
|
||||
|
||||
@@ -27,10 +27,6 @@ pub trait SyncProvider: Send + Sync {
|
||||
fn backend_name(&self) -> &'static str;
|
||||
/// Returns true if the user is currently authenticated with this backend.
|
||||
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
|
||||
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
|
||||
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 {
|
||||
(**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> {
|
||||
(**self).fetch_leaderboard().await
|
||||
}
|
||||
|
||||
@@ -283,6 +283,15 @@ pub struct ForfeitEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
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
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
|
||||
@@ -31,7 +31,8 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
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_modal::{
|
||||
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
|
||||
/// player's last window size always wins.
|
||||
ToggleSmartDefaultSize,
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
ScanThemes,
|
||||
SyncNow,
|
||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||
ConnectSync,
|
||||
@@ -293,6 +296,7 @@ impl SettingsButton {
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
SettingsButton::SelectBackground(_) => 80,
|
||||
SettingsButton::SelectTheme(_) => 85,
|
||||
SettingsButton::ScanThemes => 86,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
SettingsButton::ConnectSync => 91,
|
||||
@@ -377,6 +381,7 @@ impl Plugin for SettingsPlugin {
|
||||
sync_settings_panel_visibility,
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
handle_scan_themes,
|
||||
update_sync_status_text,
|
||||
update_card_back_text,
|
||||
update_background_text,
|
||||
@@ -1070,6 +1075,9 @@ fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::ScanThemes => {
|
||||
// Handled by `handle_scan_themes`.
|
||||
}
|
||||
SettingsButton::SyncNow
|
||||
| SettingsButton::ConnectSync
|
||||
| SettingsButton::DisconnectSync
|
||||
@@ -1637,6 +1645,7 @@ fn spawn_settings_panel(
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
import_themes_row(body, font_res);
|
||||
|
||||
// --- Sync ---
|
||||
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
|
||||
/// Settings icon button ships with one because the glyph alone (`+`, `−`,
|
||||
/// `⇄`) 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(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
|
||||
@@ -19,15 +19,61 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use tower_governor::{
|
||||
errors::GovernorError,
|
||||
governor::GovernorConfigBuilder,
|
||||
key_extractor::SmartIpKeyExtractor,
|
||||
key_extractor::{KeyExtractor, SmartIpKeyExtractor},
|
||||
GovernorLayer,
|
||||
};
|
||||
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`].
|
||||
///
|
||||
/// 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 {
|
||||
// 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/push", post(sync::push))
|
||||
.route("/api/replays", post(replays::upload))
|
||||
@@ -77,6 +123,25 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
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.
|
||||
let auth_routes = Router::new()
|
||||
.route("/api/auth/register", post(auth::register))
|
||||
|
||||
@@ -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 1–10 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
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user