test/docs: add rate limiting regression tests

This commit is contained in:
Marco Allegretti 2026-02-12 11:47:38 +01:00
parent be1c91feae
commit aa2e7894b4
5 changed files with 247 additions and 30 deletions

View file

@ -1,26 +1,43 @@
# Likwid Backend Configuration
# Copy this file to .env and configure as needed
# Database connection URL
DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid
# Server configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# JWT Secret for authentication tokens
# IMPORTANT: Change this in production!
JWT_SECRET=change-me-in-production
# =============================================================================
# DEMO MODE
# =============================================================================
# Enable demo mode for public demonstration instances.
# When enabled:
# - Restricts destructive actions (delete communities, modify instance settings)
# - Enables demo accounts (contributor, moderator, observer) with password: demo123
# - Loads seed data with realistic governance history
# - Data can be reset via POST /api/demo/reset
#
# Set to true for demo/showcase instances, false for production
DEMO_MODE=false
# Likwid Backend Configuration
# Copy this file to .env and configure as needed
# Database connection URL
DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid
# Server configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# JWT Secret for authentication tokens
# IMPORTANT: Change this in production!
JWT_SECRET=change-me-in-production
# =============================================================================
# RATE LIMITING
# =============================================================================
# The backend applies a global fixed-window rate limiter (60s window).
#
# - Set RATE_LIMIT_ENABLED=false to disable all rate limiting.
# - Set any *_RPM variable to 0 to disable that specific limiter.
#
# Per-IP rate limit (all endpoints except / and /health)
RATE_LIMIT_IP_RPM=300
# Per-user rate limit (only applies when Authorization: Bearer <jwt> is present)
RATE_LIMIT_USER_RPM=1200
# Auth endpoints rate limit (applies per IP for /api/auth/login and /api/auth/register)
RATE_LIMIT_AUTH_RPM=30
# Master toggle
RATE_LIMIT_ENABLED=true
# =============================================================================
# DEMO MODE
# =============================================================================
# Enable demo mode for public demonstration instances.
# When enabled:
# - Restricts destructive actions (delete communities, modify instance settings)
# - Enables demo accounts (contributor, moderator, observer) with password: demo123
# - Loads seed data with realistic governance history
# - Data can be reset via POST /api/demo/reset
#
# Set to true for demo/showcase instances, false for production
DEMO_MODE=false

1
backend/Cargo.lock generated
View file

@ -1825,6 +1825,7 @@ dependencies = [
"sqlx",
"thiserror 2.0.18",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",

View file

@ -30,3 +30,6 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "rus
wasmtime = "19"
wasmtime-wasi = "19"
slug = "0.1"
[dev-dependencies]
tower = "0.5"

View file

@ -202,7 +202,17 @@ pub async fn rate_limit_middleware(
#[cfg(test)]
mod tests {
use super::FixedWindowLimiter;
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::routing::get;
use axum::Router;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
use crate::auth::jwt::create_token;
use crate::config::Config;
use std::time::Duration;
#[tokio::test]
@ -229,4 +239,148 @@ mod tests {
assert!(limiter.check("k").await.is_ok());
}
}
#[tokio::test]
async fn middleware_bypasses_health() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 1;
cfg.rate_limit_user_rpm = 0;
cfg.rate_limit_auth_rpm = 0;
let cfg = Arc::new(cfg);
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
RateLimitState::new(cfg.clone()),
rate_limit_middleware,
));
for _ in 0..5 {
let req = Request::builder()
.method("GET")
.uri("/health")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn middleware_enforces_ip_rate_limit() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 2;
cfg.rate_limit_user_rpm = 0;
cfg.rate_limit_auth_rpm = 0;
let cfg = Arc::new(cfg);
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
RateLimitState::new(cfg.clone()),
rate_limit_middleware,
));
for _ in 0..2 {
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());
}
#[tokio::test]
async fn middleware_enforces_auth_rate_limit_on_login() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 0;
cfg.rate_limit_user_rpm = 0;
cfg.rate_limit_auth_rpm = 1;
let cfg = Arc::new(cfg);
let app = Router::new()
.route("/api/auth/login", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
RateLimitState::new(cfg.clone()),
rate_limit_middleware,
));
let req = Request::builder()
.method("GET")
.uri("/api/auth/login")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let req = Request::builder()
.method("GET")
.uri("/api/auth/login")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());
}
#[tokio::test]
async fn middleware_enforces_user_rate_limit_when_token_is_valid() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 0;
cfg.rate_limit_user_rpm = 1;
cfg.rate_limit_auth_rpm = 0;
cfg.jwt_secret = "testsecret".to_string();
let cfg = Arc::new(cfg);
let user_id = Uuid::new_v4();
let token = create_token(user_id, "u", &cfg.jwt_secret).unwrap();
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
RateLimitState::new(cfg.clone()),
rate_limit_middleware,
));
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());
}
}

View file

@ -7,7 +7,7 @@ Likwid is configured through environment variables and database settings.
### Backend
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| --- | --- | --- | --- |
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
| `JWT_SECRET` | Yes | - | Secret for signing JWT tokens |
| `SERVER_HOST` | No | `127.0.0.1` | Bind address |
@ -22,7 +22,7 @@ Likwid is configured through environment variables and database settings.
### Frontend
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| --- | --- | --- | --- |
| `API_BASE` | No | `http://localhost:3000` | Backend API URL |
| `PUBLIC_API_BASE` | No | Same as API_BASE | Public-facing API URL |
| `INTERNAL_API_BASE` | No | - | Server-side API URL (e.g. `http://backend:3000` in container deployments) |
@ -32,17 +32,20 @@ Likwid is configured through environment variables and database settings.
Managed via the Admin panel or API:
### General
- **Instance Name** - Display name for your Likwid instance
- **Instance Description** - Brief description
- **Registration** - Open, invite-only, or closed
- **Email Verification** - Required or optional
### Features
- **Community Creation** - Who can create communities
- **Public Read Access** - Allow anonymous browsing
- **Federation** - Enable cross-instance communication
### Plugins
- **Active Voting Methods** - Which methods are available
- **Default Voting Method** - Instance-wide default
- **Active Integrations** - GitLab, Matrix, etc.
@ -63,6 +66,7 @@ Each community can configure:
```
### Voting Method Options
- `approval` - Approval voting
- `ranked_choice` - Instant runoff
- `schulze` - Condorcet method
@ -70,6 +74,7 @@ Each community can configure:
- `quadratic` - Voice credit allocation
### Transparency Levels
- `full` - All votes visible after closing
- `anonymous` - Only totals visible
- `private` - Results only, no breakdown
@ -77,17 +82,52 @@ Each community can configure:
## API Configuration
### Rate Limiting
Rate limiting is configured via backend environment variables.
Behavior:
- **Window**: fixed 60s windows (counters reset every minute).
- **Scope**: applied as global Axum middleware for all routes.
- **Bypasses**:
- `/` and `/health` (any method)
- all `OPTIONS` requests (CORS preflight)
- **Buckets**:
- **Auth endpoints**: `RATE_LIMIT_AUTH_RPM` is applied *per IP* for (and replaces other limiters on these routes):
- `/api/auth/login`
- `/api/auth/register`
- **Per-IP**: `RATE_LIMIT_IP_RPM` is applied per IP for all other endpoints.
- **Per-user (authenticated)**: `RATE_LIMIT_USER_RPM` is additionally applied per user *when* a valid `Authorization: Bearer <jwt>` header is present.
IP detection order:
- `x-forwarded-for` (first IP in list)
- `x-real-ip`
- TCP peer address (Axum `ConnectInfo`)
Responses when limited:
- **HTTP**: `429 Too Many Requests`
- **Header**: `Retry-After: <seconds>`
- **Body**: JSON `{ "error": "Rate limit exceeded" }`
Disabling:
- Set `RATE_LIMIT_ENABLED=false` to disable all rate limiting.
- Set any `*_RPM` value to `0` to disable that specific limiter.
### CORS
By default, CORS allows all origins in development. For production:
```
```bash
CORS_ALLOWED_ORIGINS=https://likwid.example.org
```
## Logging
### Log Levels
- `trace` - Very detailed debugging
- `debug` - Debugging information
- `info` - Normal operation
@ -95,7 +135,9 @@ CORS_ALLOWED_ORIGINS=https://likwid.example.org
- `error` - Error conditions
### Log Format
Logs are output in JSON format for easy parsing:
```json
{"timestamp":"2026-01-27T12:00:00Z","level":"INFO","message":"Server started","port":3000}
```