use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Serialize;
use crate::shared::{
user_quota::{UserQuota, UserQuotaPatch},
HttpError, HttpResult, Z32Pubkey,
};
use super::super::app_state::AppState;
#[derive(Debug, Serialize)]
pub struct UserQuotaResponse {
pub effective: UserQuota,
pub overrides: UserQuota,
}
pub async fn get_user_quota(
State(state): State<AppState>,
Path(pubkey): Path<Z32Pubkey>,
) -> HttpResult<impl IntoResponse> {
let user = state
.user_service
.get_or_http_error(&pubkey.0, false)
.await?;
let overrides = user.quota();
let effective =
overrides.resolve_with_defaults(state.default_storage_mb, &state.default_quotas);
Ok(Json(UserQuotaResponse {
effective,
overrides,
}))
}
pub async fn patch_user_quota(
State(state): State<AppState>,
Path(pubkey): Path<Z32Pubkey>,
Json(patch): Json<UserQuotaPatch>,
) -> HttpResult<impl IntoResponse> {
patch
.validate()
.map_err(|e| HttpError::new_with_message(StatusCode::UNPROCESSABLE_ENTITY, e))?;
state.user_service.patch_quota(&pubkey.0, &patch).await?;
Ok(StatusCode::OK)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use axum_test::TestServer;
use super::*;
use crate::admin_server::app::create_app;
use crate::data_directory::quota_config::BandwidthQuota;
use crate::persistence::files::FileService;
use crate::AppContext;
fn create_test_server(context: &AppContext) -> TestServer {
TestServer::new(create_app(
AppState::new(
context.sql_db.clone(),
FileService::new_from_context(context).unwrap(),
"",
context.user_service.clone(),
),
"test",
))
.unwrap()
}
fn create_test_server_with_defaults(context: &AppContext) -> TestServer {
use crate::data_directory::DefaultQuotasToml;
let mut state = AppState::new(
context.sql_db.clone(),
FileService::new_from_context(context).unwrap(),
"",
context.user_service.clone(),
);
state.default_storage_mb = Some(100);
state.default_quotas = DefaultQuotasToml {
rate_read: Some(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_write: Some(BandwidthQuota::from_str("5mb/s").unwrap()),
..Default::default()
};
TestServer::new(create_app(state, "test")).unwrap()
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_user_quota_crud() {
use crate::persistence::sql::user::UserRepository;
use pubky_common::crypto::Keypair;
let context = AppContext::test().await;
let server = create_test_server(&context);
let keypair = Keypair::random();
let pubkey = keypair.public_key();
UserRepository::create(&pubkey, &mut context.sql_db.pool().into())
.await
.unwrap();
let url = format!("/users/{}/quota", pubkey.z32());
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
response.assert_status_ok();
let json: serde_json::Value = response.json();
assert_eq!(json["overrides"], serde_json::json!({}));
assert_eq!(json["effective"]["storage_quota_mb"], "unlimited");
assert_eq!(json["effective"]["rate_read"], "unlimited");
assert_eq!(json["effective"]["rate_write"], "unlimited");
let body = serde_json::json!({
"storage_quota_mb": 500,
"rate_read": "100mb/m"
});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["effective"]["storage_quota_mb"], 500);
assert_eq!(json["effective"]["rate_read"], "100mb/m");
assert_eq!(json["effective"]["rate_write"], "unlimited");
assert_eq!(json["overrides"]["storage_quota_mb"], 500);
assert_eq!(json["overrides"]["rate_read"], "100mb/m");
assert!(json["overrides"].get("rate_write").is_none());
let body = serde_json::json!({
"storage_quota_mb": null,
"rate_read": null,
"rate_write": null
});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["overrides"], serde_json::json!({}));
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_get_effective_resolves_defaults() {
use crate::persistence::sql::user::UserRepository;
use pubky_common::crypto::Keypair;
let context = AppContext::test().await;
let server = create_test_server_with_defaults(&context);
let keypair = Keypair::random();
let pubkey = keypair.public_key();
UserRepository::create(&pubkey, &mut context.sql_db.pool().into())
.await
.unwrap();
let url = format!("/users/{}/quota", pubkey.z32());
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["effective"]["storage_quota_mb"], 100);
assert_eq!(json["effective"]["rate_read"], "10mb/s");
assert_eq!(json["effective"]["rate_write"], "5mb/s");
assert_eq!(json["overrides"], serde_json::json!({}));
let body = serde_json::json!({"storage_quota_mb": 500});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["effective"]["storage_quota_mb"], 500);
assert_eq!(json["effective"]["rate_read"], "10mb/s");
assert_eq!(json["effective"]["rate_write"], "5mb/s");
assert_eq!(json["overrides"]["storage_quota_mb"], 500);
assert!(json["overrides"].get("rate_read").is_none());
let body = serde_json::json!({"rate_read": "unlimited"});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["effective"]["storage_quota_mb"], 500);
assert_eq!(json["effective"]["rate_read"], "unlimited");
assert_eq!(json["effective"]["rate_write"], "5mb/s");
assert_eq!(json["overrides"]["storage_quota_mb"], 500);
assert_eq!(json["overrides"]["rate_read"], "unlimited");
assert!(json["overrides"].get("rate_write").is_none());
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_user_quota_invalid_rate_rejected() {
use crate::persistence::sql::user::UserRepository;
use pubky_common::crypto::Keypair;
let context = AppContext::test().await;
let server = create_test_server(&context);
let keypair = Keypair::random();
let pubkey = keypair.public_key();
UserRepository::create(&pubkey, &mut context.sql_db.pool().into())
.await
.unwrap();
let url = format!("/users/{}/quota", pubkey.z32());
let body = serde_json::json!({
"rate_read": "rubbish"
});
let response = server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_failure()
.await;
response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_user_quota_nonexistent_user() {
let context = AppContext::test().await;
let server = create_test_server(&context);
let pubkey = pubky_common::crypto::Keypair::random().public_key();
let url = format!("/users/{}/quota", pubkey.z32());
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_failure()
.await;
response.assert_status(axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_default_vs_unlimited_distinguishable() {
use crate::persistence::sql::user::UserRepository;
use pubky_common::crypto::Keypair;
let context = AppContext::test().await;
let server = create_test_server(&context);
let keypair = Keypair::random();
let pubkey = keypair.public_key();
UserRepository::create(&pubkey, &mut context.sql_db.pool().into())
.await
.unwrap();
let url = format!("/users/{}/quota", pubkey.z32());
let body = serde_json::json!({
"rate_read": "unlimited"
});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["overrides"]["rate_read"], "unlimited");
assert!(json["overrides"].get("rate_write").is_none());
assert!(json["overrides"].get("storage_quota_mb").is_none());
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_patch_user_quota_merges() {
use crate::persistence::sql::user::UserRepository;
use pubky_common::crypto::Keypair;
let context = AppContext::test().await;
let server = create_test_server(&context);
let keypair = Keypair::random();
let pubkey = keypair.public_key();
UserRepository::create(&pubkey, &mut context.sql_db.pool().into())
.await
.unwrap();
let url = format!("/users/{}/quota", pubkey.z32());
let body = serde_json::json!({
"storage_quota_mb": 500,
"rate_read": "100mb/m",
"rate_write": "50mb/m"
});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&body).unwrap().into())
.expect_success()
.await;
let patch = serde_json::json!({
"storage_quota_mb": 200
});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&patch).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["overrides"]["storage_quota_mb"], 200);
assert_eq!(json["overrides"]["rate_read"], "100mb/m");
assert_eq!(json["overrides"]["rate_write"], "50mb/m");
let patch = serde_json::json!({
"rate_write": "unlimited"
});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&patch).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["overrides"]["storage_quota_mb"], 200);
assert_eq!(json["overrides"]["rate_read"], "100mb/m");
assert_eq!(json["overrides"]["rate_write"], "unlimited");
let patch = serde_json::json!({});
server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&patch).unwrap().into())
.expect_success()
.await;
let response = server
.get(&url)
.add_header("X-Admin-Password", "test")
.expect_success()
.await;
let json: serde_json::Value = response.json();
assert_eq!(json["overrides"]["storage_quota_mb"], 200);
assert_eq!(json["overrides"]["rate_read"], "100mb/m");
assert_eq!(json["overrides"]["rate_write"], "unlimited");
}
#[tokio::test]
#[pubky_test_utils::test]
async fn test_patch_invalid_rate_rejected() {
use crate::persistence::sql::user::UserRepository;
use pubky_common::crypto::Keypair;
let context = AppContext::test().await;
let server = create_test_server(&context);
let keypair = Keypair::random();
let pubkey = keypair.public_key();
UserRepository::create(&pubkey, &mut context.sql_db.pool().into())
.await
.unwrap();
let url = format!("/users/{}/quota", pubkey.z32());
let patch = serde_json::json!({
"rate_read": "rubbish"
});
let response = server
.patch(&url)
.add_header("X-Admin-Password", "test")
.content_type("application/json")
.bytes(serde_json::to_vec(&patch).unwrap().into())
.expect_failure()
.await;
response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
}