authentication for the backend
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 11m16s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 11m16s
This commit is contained in:
parent
f78054fecd
commit
e3d4f8eac8
1067
Cargo.lock
generated
1067
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -29,3 +29,8 @@ tower = "0.5.3"
|
|||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
zxcvbn = "3.1.1"
|
zxcvbn = "3.1.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
testcontainers = "0.23.1"
|
||||||
|
testcontainers-modules = { version = "0.11.4", features = ["postgres"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub async fn rate_limiting_middleware(
|
|||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
// 1. Identify client by IP (x-client-ip header or socket address)
|
||||||
let client_ip = request
|
let client_ip = request
|
||||||
.headers()
|
.headers()
|
||||||
.get("x-client-ip")
|
.get("x-client-ip")
|
||||||
@ -25,14 +26,16 @@ pub async fn rate_limiting_middleware(
|
|||||||
.unwrap_or_else(|| "unknown".to_string())
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. Retrieve or create a TokenBucket for this IP and try to drain 1 token
|
||||||
let has_tokens = {
|
let has_tokens = {
|
||||||
let mut entry = state
|
let mut entry = state
|
||||||
.rate_limit
|
.rate_limit
|
||||||
.entry(client_ip)
|
.entry(client_ip)
|
||||||
.or_insert_with(crate::state::TokenBucket::new);
|
.or_insert_with(crate::state::TokenBucket::new);
|
||||||
entry.value_mut().take()
|
entry.value_mut().try_drain()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 3. If successful, proceed; else return 429 Too Many Requests
|
||||||
if has_tokens {
|
if has_tokens {
|
||||||
next.run(request).await
|
next.run(request).await
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
middleware::{from_fn, from_fn_with_state},
|
middleware::from_fn_with_state,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controller::{middleware::auth_middleware::auth_middleware, v1::protected::protected_router},
|
controller::middleware::auth_middleware::auth_middleware,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
10
src/lib.rs
Normal file
10
src/lib.rs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod controller;
|
||||||
|
pub mod database;
|
||||||
|
pub mod db;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod server;
|
||||||
|
pub mod service;
|
||||||
|
pub mod state;
|
||||||
|
pub mod utils;
|
||||||
12
src/main.rs
12
src/main.rs
@ -1,14 +1,4 @@
|
|||||||
mod config;
|
use rhythm_backend::{config, database, errors::MainError, logging, server};
|
||||||
mod controller;
|
|
||||||
mod database;
|
|
||||||
mod db;
|
|
||||||
mod errors;
|
|
||||||
mod logging;
|
|
||||||
mod server;
|
|
||||||
mod service;
|
|
||||||
mod state;
|
|
||||||
mod utils;
|
|
||||||
use errors::MainError;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), MainError> {
|
async fn main() -> Result<(), MainError> {
|
||||||
|
|||||||
24
src/state.rs
24
src/state.rs
@ -3,9 +3,6 @@ use sqlx::PgPool;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
const REFILL_RATE: f64 = 1.0;
|
|
||||||
const MAX_TOKENS: f64 = 10.0;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: PgPool,
|
pub db: PgPool,
|
||||||
@ -18,21 +15,34 @@ pub struct TokenBucket {
|
|||||||
pub last_refill: Instant,
|
pub last_refill: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rate Limiting Configuration ---
|
||||||
|
// Strategy: 5 requests per minute.
|
||||||
|
// This means 1 new token is mathematically generated every 12 seconds.
|
||||||
|
const REQUESTS_PER_MINUTE: f64 = 5.0;
|
||||||
|
// Maximum burst capacity. Users can make up to 5 rapid requests if their bucket is full.
|
||||||
|
const BUCKET_CAPACITY: f64 = 5.0;
|
||||||
|
|
||||||
impl TokenBucket {
|
impl TokenBucket {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tokens: MAX_TOKENS,
|
tokens: BUCKET_CAPACITY,
|
||||||
last_refill: Instant::now(),
|
last_refill: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take(&mut self) -> bool {
|
/// Refills the bucket based on time passed since the last request.
|
||||||
|
/// Mathematically simulates a background process adding 1 token every (60/RPM) seconds.
|
||||||
|
fn refill(&mut self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
|
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
|
||||||
|
let tokens_per_second = REQUESTS_PER_MINUTE / 60.0;
|
||||||
|
|
||||||
self.tokens = (self.tokens + elapsed * REFILL_RATE).min(MAX_TOKENS);
|
self.tokens = (self.tokens + elapsed * tokens_per_second).min(BUCKET_CAPACITY);
|
||||||
self.last_refill = now;
|
self.last_refill = now;
|
||||||
|
}
|
||||||
|
/// Attempts to consume 1 token from the bucket.
|
||||||
|
pub fn try_drain(&mut self) -> bool {
|
||||||
|
self.refill();
|
||||||
if self.tokens >= 1.0 {
|
if self.tokens >= 1.0 {
|
||||||
self.tokens -= 1.0;
|
self.tokens -= 1.0;
|
||||||
true
|
true
|
||||||
|
|||||||
3
tests/auth/mod.rs
Normal file
3
tests/auth/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod registration_login;
|
||||||
|
pub mod security;
|
||||||
|
pub mod session;
|
||||||
34
tests/auth/registration_login.rs
Normal file
34
tests/auth/registration_login.rs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
use crate::common::{setup_app, spawn_server};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_and_login() {
|
||||||
|
let (app, _db) = setup_app().await;
|
||||||
|
let (base_url, client) = spawn_server(app).await;
|
||||||
|
let email = format!("user_{}@test.com", uuid::Uuid::new_v4());
|
||||||
|
|
||||||
|
// Register
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/auth/register", base_url))
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
|
||||||
|
assert!(resp.status().is_success(), "Register failed: {}", resp.text().await.unwrap_or_default());
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap();
|
||||||
|
assert!(body["access_token"].is_string());
|
||||||
|
|
||||||
|
// Login success
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
|
||||||
|
// Login failure
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "WrongPassword"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), 401);
|
||||||
|
}
|
||||||
83
tests/auth/security.rs
Normal file
83
tests/auth/security.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
use crate::common::{setup_app, spawn_server};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rate_limiting_blocks_spam() {
|
||||||
|
let (app, _db) = setup_app().await;
|
||||||
|
let (base_url, client) = spawn_server(app).await;
|
||||||
|
|
||||||
|
// Send 6 requests CONCURRENTLY so they arrive nearly simultaneously
|
||||||
|
// Capacity is 5, so exactly 5 should pass and 1 should fail.
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..6 {
|
||||||
|
let c = client.clone();
|
||||||
|
let url = format!("{}/api/v1/auth/login", base_url);
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
c.post(&url)
|
||||||
|
.header("x-client-ip", "1.2.3.4")
|
||||||
|
.json(&serde_json::json!({"email": "a@test.com", "password": "b"}))
|
||||||
|
.send().await.unwrap()
|
||||||
|
.status().as_u16()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let statuses: Vec<u16> = futures_util::future::join_all(handles).await
|
||||||
|
.into_iter().map(|r| r.unwrap()).collect();
|
||||||
|
|
||||||
|
let successes = statuses.iter().filter(|&&s| s != 429).count();
|
||||||
|
let blocked = statuses.iter().filter(|&&s| s == 429).count();
|
||||||
|
|
||||||
|
assert!(successes <= 5, "At most 5 rapid requests should be allowed, got {} passing", successes);
|
||||||
|
assert!(blocked >= 1, "At least 1 request should be rate-limited (429)");
|
||||||
|
|
||||||
|
// Different IP should still work
|
||||||
|
let ok = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.header("x-client-ip", "5.6.7.8")
|
||||||
|
.json(&serde_json::json!({"email": "a@test.com", "password": "b"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(ok.status(), 429, "Different IP should not be rate-limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_anti_enumeration_timing() {
|
||||||
|
let (app, _db) = setup_app().await;
|
||||||
|
let (base_url, client) = spawn_server(app).await;
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let _ = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.header("x-client-ip", "1.1.1.1") // Different IP to avoid rate limits from previous requests in other tests if run concurrently
|
||||||
|
.json(&serde_json::json!({"email": "ghost_not_real@test.com", "password": "irrelevant"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
let duration_nonexistent = start.elapsed();
|
||||||
|
|
||||||
|
let email = format!("timing_{}@test.com", uuid::Uuid::new_v4());
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/auth/register", base_url))
|
||||||
|
.header("x-client-ip", "2.2.2.2")
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let _ = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.header("x-client-ip", "3.3.3.3")
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "WrongPassword123!"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
let duration_existent = start.elapsed();
|
||||||
|
|
||||||
|
// Anti-enumeration middleware ensures BOTH take >= 150ms
|
||||||
|
assert!(duration_nonexistent >= Duration::from_millis(150), "Fast path should be padded to >= 150ms");
|
||||||
|
assert!(duration_existent >= Duration::from_millis(150), "Slow path should be padded to >= 150ms");
|
||||||
|
|
||||||
|
let diff = if duration_nonexistent > duration_existent {
|
||||||
|
duration_nonexistent - duration_existent
|
||||||
|
} else {
|
||||||
|
duration_existent - duration_nonexistent
|
||||||
|
};
|
||||||
|
assert!(diff < Duration::from_millis(300),
|
||||||
|
"Timing difference should be <300ms because both paths are padded by random delay: got {:?}", diff);
|
||||||
|
}
|
||||||
62
tests/auth/session.rs
Normal file
62
tests/auth/session.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
use crate::common::{setup_app, spawn_server};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_protected_route_requires_auth() {
|
||||||
|
let (app, _db) = setup_app().await;
|
||||||
|
let (base_url, client) = spawn_server(app).await;
|
||||||
|
|
||||||
|
// No token → 401
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{}/api/v1/protected/ping", base_url))
|
||||||
|
.send().await.unwrap();
|
||||||
|
assert_eq!(resp.status(), 401, "Protected route should require auth");
|
||||||
|
|
||||||
|
// With token → 200
|
||||||
|
let email = format!("protected_{}@test.com", uuid::Uuid::new_v4());
|
||||||
|
let reg = client
|
||||||
|
.post(format!("{}/api/v1/auth/register", base_url))
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
let token: serde_json::Value = reg.json().await.unwrap();
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{}/api/v1/protected/ping", base_url))
|
||||||
|
.bearer_auth(token["access_token"].as_str().unwrap())
|
||||||
|
.send().await.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200, "Protected route should succeed with valid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_refresh_and_logout_all() {
|
||||||
|
let (app, _db) = setup_app().await;
|
||||||
|
let (base_url, client) = spawn_server(app).await;
|
||||||
|
|
||||||
|
// Register + login to get a valid session
|
||||||
|
let email = format!("refresh_{}@test.com", uuid::Uuid::new_v4());
|
||||||
|
let reg = client
|
||||||
|
.post(format!("{}/api/v1/auth/register", base_url))
|
||||||
|
.json(&serde_json::json!({"email": email, "password": "SuperSecureP@ssw0rd2024!"}))
|
||||||
|
.send().await.unwrap();
|
||||||
|
let _token: serde_json::Value = reg.json().await.unwrap();
|
||||||
|
|
||||||
|
// Refresh should work
|
||||||
|
let refreshed = client
|
||||||
|
.post(format!("{}/api/v1/auth/refresh", base_url))
|
||||||
|
.send().await.unwrap();
|
||||||
|
assert!(refreshed.status().is_success(), "Refresh should succeed with cookie");
|
||||||
|
let new_token: serde_json::Value = refreshed.json().await.unwrap();
|
||||||
|
assert!(new_token["access_token"].is_string());
|
||||||
|
|
||||||
|
// Logout all
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/protected/auth/logout-all", base_url))
|
||||||
|
.bearer_auth(new_token["access_token"].as_str().unwrap())
|
||||||
|
.send().await.unwrap();
|
||||||
|
assert!(resp.status().is_success(), "logout-all should succeed");
|
||||||
|
|
||||||
|
// After logout-all, refresh should fail
|
||||||
|
let fail = client
|
||||||
|
.post(format!("{}/api/v1/auth/refresh", base_url))
|
||||||
|
.send().await.unwrap();
|
||||||
|
assert_eq!(fail.status(), 401, "Refresh should fail after logout-all");
|
||||||
|
}
|
||||||
2
tests/auth_tests.rs
Normal file
2
tests/auth_tests.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
mod common;
|
||||||
|
mod auth;
|
||||||
103
tests/basic_test.rs
Normal file
103
tests/basic_test.rs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use rhythm_backend::{
|
||||||
|
controller, state::AppState,
|
||||||
|
};
|
||||||
|
use axum::Router;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
async fn setup_app() -> (Router, PgPool) {
|
||||||
|
let db_url = "postgres://user:password@localhost:5432/rhythm-dev?sslmode=disable";
|
||||||
|
|
||||||
|
let db = PgPool::connect(db_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to Postgres at localhost:5432");
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&db)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: db.clone(),
|
||||||
|
jwt_secret: "test-secret-key-12345678901234567890".to_string(),
|
||||||
|
rate_limit: Arc::new(DashMap::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = controller::router(state.clone()).with_state(state);
|
||||||
|
|
||||||
|
(app, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_and_login() {
|
||||||
|
let (app, _db) = setup_app().await;
|
||||||
|
|
||||||
|
// Start the server on a random port
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app.into_make_service()).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give server a moment to start
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let base_url = format!("http://127.0.0.1:{}", port);
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Use a unique email so we don't conflict with previous test runs
|
||||||
|
let unique_email = format!("testuser_{}@test.com", uuid::Uuid::new_v4());
|
||||||
|
|
||||||
|
// Test 1: Register
|
||||||
|
let reg_resp = client
|
||||||
|
.post(format!("{}/api/v1/auth/register", base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": unique_email,
|
||||||
|
"password": "SuperSecureP@ssw0rd2024!"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Register status: {}", reg_resp.status());
|
||||||
|
assert!(
|
||||||
|
reg_resp.status().is_success(),
|
||||||
|
"Register should succeed: {}",
|
||||||
|
reg_resp.text().await.unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 2: Login with correct password
|
||||||
|
let login_resp = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": unique_email,
|
||||||
|
"password": "SuperSecureP@ssw0rd2024!"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Login status: {}", login_resp.status());
|
||||||
|
assert!(login_resp.status().is_success(), "Login should succeed with correct password: {}", login_resp.text().await.unwrap_or_default());
|
||||||
|
|
||||||
|
let body: serde_json::Value = login_resp.json().await.unwrap();
|
||||||
|
assert!(body.get("access_token").is_some(), "Login response should have access_token");
|
||||||
|
|
||||||
|
// Test 3: Login with wrong password
|
||||||
|
let bad_login = client
|
||||||
|
.post(format!("{}/api/v1/auth/login", base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": unique_email,
|
||||||
|
"password": "WrongPassword1!"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Bad login status: {}", bad_login.status());
|
||||||
|
assert_eq!(bad_login.status(), 401, "Login with wrong password should return 401");
|
||||||
|
}
|
||||||
48
tests/common/mod.rs
Normal file
48
tests/common/mod.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use std::time::Duration;
|
||||||
|
use rhythm_backend::{controller, state::AppState};
|
||||||
|
use axum::Router;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
pub async fn setup_app() -> (Router, PgPool) {
|
||||||
|
let db_url = "postgres://user:password@localhost:5432/rhythm-dev?sslmode=disable";
|
||||||
|
|
||||||
|
let db = PgPool::connect(db_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to Postgres at localhost:5432");
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&db)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: db.clone(),
|
||||||
|
jwt_secret: "test-secret-key-12345678901234567890".to_string(),
|
||||||
|
rate_limit: Arc::new(DashMap::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = controller::router(state.clone()).with_state(state);
|
||||||
|
(app, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_server(app: axum::Router) -> (String, Client) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app.into_make_service()).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let base_url = format!("http://127.0.0.1:{}", port);
|
||||||
|
let client = Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(base_url, client)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user