docs(project): add README, CI workflow, migration guide, and fix asset docs
- README.md: player-facing install, controls, features, and test instructions - .github/workflows/ci.yml: clippy + headless tests + release build on push/PR - solitaire_server/migrations/README.md: naming convention and workflow for adding future schema migrations - ARCHITECTURE.md §14: rewrite Asset Pipeline to reflect procedural rendering (no image files used; audio only, embedded via include_bytes!) - ARCHITECTURE.md §2 / §13: fix workspace structure and audio file listing - CLAUDE.md: clarify asset embedding rule (audio only; visuals are procedural) - server_tests.rs: add auth_rate_limit_returns_429_on_11th_request test using build_router() (rate limiting ON) to verify the GovernorLayer is wired correctly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# Database Migrations
|
||||
|
||||
Migrations are run automatically at server startup via `sqlx::migrate!("./migrations")`.
|
||||
|
||||
## Naming convention
|
||||
|
||||
```
|
||||
NNN_description.sql
|
||||
```
|
||||
|
||||
- `NNN` — zero-padded three-digit sequence number (`001`, `002`, …)
|
||||
- `description` — snake_case description of what the migration does
|
||||
|
||||
Examples:
|
||||
```
|
||||
001_initial.sql
|
||||
002_add_user_display_name.sql
|
||||
003_weekly_goals_table.sql
|
||||
```
|
||||
|
||||
`sqlx` tracks which migrations have run in the `_sqlx_migrations` table and only applies new ones. Never edit or delete an existing migration file after it has been applied to any database — add a new migration instead.
|
||||
|
||||
## Adding a migration
|
||||
|
||||
1. Create `migrations/NNN_description.sql` where `NNN` is the next available number.
|
||||
2. Write idempotent SQL (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, etc.) where possible.
|
||||
3. Update the sqlx offline query cache so the server builds without a live DB:
|
||||
```bash
|
||||
export DATABASE_URL=sqlite://solitaire.db
|
||||
sqlx database create
|
||||
sqlx migrate run --source solitaire_server/migrations
|
||||
cargo sqlx prepare --workspace
|
||||
```
|
||||
4. Commit both the migration file and the updated `.sqlx/` query cache together.
|
||||
|
||||
## Current schema
|
||||
|
||||
See `001_initial.sql` for the full initial schema: `users`, `sync_state`, `daily_challenges`, `leaderboard`.
|
||||
@@ -1248,3 +1248,64 @@ async fn refresh_token_rejected_on_protected_routes() {
|
||||
"refresh token must be rejected on protected endpoints"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate-limiting test (uses the production router with rate limiting enabled)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The 11th request to an auth endpoint within the rate-limit window must
|
||||
/// return 429 Too Many Requests.
|
||||
///
|
||||
/// Uses [`solitaire_server::build_router`] (rate limiting ON) rather than
|
||||
/// [`build_test_router`] so the GovernorLayer is actually applied.
|
||||
/// All 11 requests share the same router clone — cloning an Axum Router with
|
||||
/// GovernorLayer clones the inner `Arc`, so the request counter is shared.
|
||||
#[tokio::test]
|
||||
async fn auth_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);
|
||||
|
||||
let body_bytes = serde_json::to_vec(&serde_json::json!({
|
||||
"username": "ratelimituser",
|
||||
"password": "password1!"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// First 10 requests consume the burst allowance (burst_size = 10).
|
||||
// The status may be 200 (first registration) or 400/409 (duplicate username)
|
||||
// on retries — what matters is that none of them are 429.
|
||||
for i in 0..10 {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/register")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(body_bytes.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 rejected by the rate limiter.
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/register")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(body_bytes))
|
||||
.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 request must be rate-limited with 429"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user