mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
test/docs: add rate limiting regression tests
This commit is contained in:
parent
be1c91feae
commit
aa2e7894b4
5 changed files with 247 additions and 30 deletions
|
|
@ -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
1
backend/Cargo.lock
generated
|
|
@ -1825,6 +1825,7 @@ dependencies = [
|
|||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in a new issue