feat: tests and big refactor

This commit is contained in:
Ninjdai 2025-08-09 17:43:31 +02:00
parent 05e8366611
commit 82f2bb4a61
8 changed files with 402 additions and 238 deletions

225
src/lib.rs Normal file
View file

@ -0,0 +1,225 @@
use std::{net::{Ipv4Addr, SocketAddr}, path::PathBuf, sync::{Arc, LazyLock}};
use axum::{extract::State, http::HeaderMap, middleware, routing::get};
use clap::{Parser, Subcommand};
use reqwest::{header::USER_AGENT};
use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait, Schema};
use tokio::{sync::broadcast::{self, Sender}};
use utoipa::{openapi::{security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, ContactBuilder, InfoBuilder, LicenseBuilder}, Modify, OpenApi};
use utoipa_axum::router::OpenApiRouter;
use utoipa_swagger_ui::{Config, SwaggerUi};
use utoipa_axum::routes;
use crate::{entities::prelude::BookInstance, routes::auth::{Keys, DEFAULT_TOKEN_EXPIRY_TIME}, utils::events::Event};
pub mod entities;
pub mod utils;
pub mod routes;
#[derive(Parser)]
#[command(name = "Alexandria")]
#[command(version = "1.0")]
#[command(about = "BAL management server", long_about = None)]
pub struct Cli {
/// Path to the sqlite database [default: ./alexandria.db]
#[arg(long, short, global = true, value_name = "FILE")]
pub database: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Clone)]
pub enum Command {
/// Serves the web server
Run {
/// Port on which to serve the web server
#[arg(short, long, default_value_t = 3000)]
port: u16,
/// How many seconds generated JWTs are valid for. Default equates to 6 months
#[arg(long, default_value_t = DEFAULT_TOKEN_EXPIRY_TIME)]
token_expiration_time: u64,
},
/// Open a TUI to manage user accounts
User
}
impl Cli {
pub fn command(&self) -> Command {
self.command.clone().unwrap_or(Command::Run { port: 3000, token_expiration_time: DEFAULT_TOKEN_EXPIRY_TIME })
}
}
pub struct AppState {
app_name: String,
db_conn: Arc<DatabaseConnection>,
event_bus: Sender<Event>,
web_client: reqwest::Client
}
async fn index(
State(state): State<Arc<AppState>>
) ->String {
let app_name = &state.app_name;
let db_conn = &state.db_conn;
let status: &str = match db_conn.ping().await {
Ok(_) => "working",
Err(_) => "erroring"
};
let book_count = BookInstance::find().count(db_conn.as_ref()).await.unwrap();
format!("Hello from {app_name}! Database is {status}. We currently have {book_count} books in stock !")
}
static KEYS: LazyLock<Keys> = LazyLock::new(|| {
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
Keys::new(secret.as_bytes())
});
pub static CLI: LazyLock<Cli> = LazyLock::new(|| {
Cli::parse()
});
pub async fn create_tables<C>(db_conn: &C) -> Result<(), DbErr>
where C: ConnectionTrait,{
let builder = db_conn.get_database_backend();
let schema = Schema::new(builder);
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::Book).if_not_exists())).await {
log::error!(target: "database", "Error while creating book table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::BookInstance).if_not_exists())).await {
log::error!(target: "database", "Error while creating book_instance table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::Owner).if_not_exists())).await {
log::error!(target: "database", "Error while creating owner table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::User).if_not_exists())).await {
log::error!(target: "database", "Error while creating user table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::Bal).if_not_exists())).await {
log::error!(target: "database", "Error while creating bal table: {err:?}");
return Err(err);
}
Ok(())
}
pub async fn run_server(db: Arc<DatabaseConnection>, port: u16, serve_docs: bool) {
let (event_bus, _) = broadcast::channel(16);
if std::env::var("JWT_SECRET").is_err() {
log::error!("JWT_SECRET is not set");
return;
}
let mut default_headers = HeaderMap::new();
default_headers.append(USER_AGENT, "Alexandria/1.0 (unionetudianteauvergne@gmail.com)".parse().unwrap());
let shared_state = Arc::new(AppState {
app_name: "Alexandria".to_string(),
db_conn: db,
event_bus,
web_client: reqwest::Client::builder().default_headers(default_headers).build().expect("creating the reqwest client failed")
});
#[derive(OpenApi)]
#[openapi(
tags(
(name = "book-api", description = "Book management endpoints.")
),
modifiers(&SecurityAddon)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"jwt",
SecurityScheme::Http(
HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()
)
)
}
}
let open_api_router = OpenApiRouter::new()
// Book API
.routes(routes!(routes::book::get_book_by_ean))
.routes(routes!(routes::book::get_book_by_id))
.routes(routes!(routes::book::create_book))
// Book Instance API
.routes(routes!(routes::book_instance::get_book_instance_by_id))
.routes(routes!(routes::book_instance::create_book_instance))
.routes(routes!(routes::book_instance::update_book_instance))
.routes(routes!(routes::book_instance::sell_book_instance))
.routes(routes!(routes::book_instance::bulk_create_book_instance))
.routes(routes!(routes::book_instance::get_bal_owner_book_instances))
.routes(routes!(routes::book_instance::get_bal_book_instances_by_ean))
.routes(routes!(routes::book_instance::search_bal_book_instances))
// Owner API
.routes(routes!(routes::owner::get_owner_by_id))
.routes(routes!(routes::owner::create_owner))
.routes(routes!(routes::owner::update_owner))
.routes(routes!(routes::owner::get_owners))
// Bal API
.routes(routes!(routes::bal::get_bal_by_id))
.routes(routes!(routes::bal::create_bal))
.routes(routes!(routes::bal::update_bal))
.routes(routes!(routes::bal::get_bals))
.routes(routes!(routes::bal::get_current_bal))
.routes(routes!(routes::bal::set_current_bal))
// Authentication
.route_layer(middleware::from_fn_with_state(shared_state.clone(), routes::auth::auth_middleware))
.routes(routes!(routes::auth::auth))
.routes(routes!(routes::auth::check_token))
// Misc
.routes(routes!(routes::misc::current_api_version))
.routes(routes!(routes::websocket::ws_handler))
.with_state(shared_state.clone());
let (mut router, mut api) = OpenApiRouter::new()
.nest("/api", open_api_router)
.route("/", get(index)) // temporary index page, will redirect/proxy to flutter app
.with_state(shared_state)
.split_for_parts();
if serve_docs {
api.info = InfoBuilder::new()
.title("Alexandria")
.description(Some("Alexandria is a server that manages books and users for Union Étudiante's book exchange"))
.contact(Some(ContactBuilder::new()
.url(Some("https://ueauvergne.fr"))
.name(Some("Union Étudiante Auvergne"))
.email(Some("unionetudianteauvergne@gmail.com"))
.build()))
.license(Some(LicenseBuilder::new().name("MIT").url(Some("https://spdx.org/licenses/MIT.html")).build()))
.version("1.0.0")
.build();
api.merge(ApiDoc::openapi());
let swagger = SwaggerUi::new("/docs/")
.url("/docs/openapi.json", api)
.config(Config::default()
.try_it_out_enabled(true)
.filter(true)
.display_request_duration(true)
.persist_authorization(true)
);
router = router.merge(swagger);
}
let listener = tokio::net::TcpListener::bind(SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port)).await.unwrap();
log::info!("Running on http://{}", listener.local_addr().unwrap());
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>()
).await.unwrap()
}

View file

@ -1,77 +1,8 @@
use std::{net::{Ipv4Addr, SocketAddr}, path::PathBuf, sync::{Arc, LazyLock}};
use std::sync::Arc;
use axum::{extract::State, http::HeaderMap, middleware, routing::get};
use clap::{Parser, Subcommand};
use reqwest::{header::USER_AGENT};
use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait, Schema};
use tokio::{sync::broadcast::{self, Sender}};
use utoipa::{openapi::{security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, ContactBuilder, InfoBuilder, LicenseBuilder}, Modify, OpenApi};
use utoipa_axum::router::OpenApiRouter;
use utoipa_swagger_ui::{Config, SwaggerUi};
use utoipa_axum::routes;
use alexandria::{create_tables, run_server, utils, Command, CLI};
use sea_orm::{Database, DatabaseConnection};
use crate::{entities::prelude::BookInstance, routes::auth::Keys, utils::events::Event};
pub mod entities;
pub mod utils;
pub mod routes;
#[derive(Parser)]
#[command(name = "Alexandria")]
#[command(version = "1.0")]
#[command(about = "BAL management server", long_about = None)]
struct Cli {
/// Path to the sqlite database [default: ./alexandria.db]
#[arg(long, short, global = true, value_name = "FILE")]
database: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Serves the web server
Run {
/// Port on which to serve the web server
#[arg(short, long, default_value_t = 3000)]
port: u16,
/// How many seconds generated JWTs are valid for. Default equates to 6 months
#[arg(long, default_value_t = 15_778_476)]
token_expiration_time: u64,
},
/// Open a TUI to manage user accounts
User
}
pub struct AppState {
app_name: String,
db_conn: Arc<DatabaseConnection>,
event_bus: Sender<Event>,
web_client: reqwest::Client
}
async fn index(
State(state): State<Arc<AppState>>
) ->String {
let app_name = &state.app_name;
let db_conn = &state.db_conn;
let status: &str = match db_conn.ping().await {
Ok(_) => "working",
Err(_) => "erroring"
};
let book_count = BookInstance::find().count(db_conn.as_ref()).await.unwrap();
format!("Hello from {app_name}! Database is {status}. We currently have {book_count} books in stock !")
}
static KEYS: LazyLock<Keys> = LazyLock::new(|| {
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
Keys::new(secret.as_bytes())
});
static CLI: LazyLock<Cli> = LazyLock::new(|| {
Cli::parse()
});
#[tokio::main]
async fn main() {
@ -105,151 +36,9 @@ async fn main() {
return;
};
match &CLI.command {
Commands::Run {port,..} => run_server(db, *port).await,
Commands::User => utils::cli::manage_users(db).await
match &CLI.command() {
Command::Run {port,..} => run_server(db, *port, true).await,
Command::User => utils::cli::manage_users(db).await
}
}
async fn create_tables<C>(db_conn: &C) -> Result<(), DbErr>
where C: ConnectionTrait,{
let builder = db_conn.get_database_backend();
let schema = Schema::new(builder);
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::Book).if_not_exists())).await {
log::error!(target: "database", "Error while creating book table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::BookInstance).if_not_exists())).await {
log::error!(target: "database", "Error while creating book_instance table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::Owner).if_not_exists())).await {
log::error!(target: "database", "Error while creating owner table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::User).if_not_exists())).await {
log::error!(target: "database", "Error while creating user table: {err:?}");
return Err(err);
}
if let Err(err) = db_conn.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::Bal).if_not_exists())).await {
log::error!(target: "database", "Error while creating bal table: {err:?}");
return Err(err);
}
Ok(())
}
async fn run_server(db: Arc<DatabaseConnection>, port: u16) {
let (event_bus, _) = broadcast::channel(16);
if std::env::var("JWT_SECRET").is_err() {
log::error!("JWT_SECRET is not set");
return;
}
let mut default_headers = HeaderMap::new();
default_headers.append(USER_AGENT, "Alexandria/1.0 (unionetudianteauvergne@gmail.com)".parse().unwrap());
let shared_state = Arc::new(AppState {
app_name: "Alexandria".to_string(),
db_conn: db,
event_bus,
web_client: reqwest::Client::builder().default_headers(default_headers).build().expect("creating the reqwest client failed")
});
#[derive(OpenApi)]
#[openapi(
tags(
(name = "book-api", description = "Book management endpoints.")
),
modifiers(&SecurityAddon)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"jwt",
SecurityScheme::Http(
HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()
)
)
}
}
let open_api_router = OpenApiRouter::new()
// Book API
.routes(routes!(routes::book::get_book_by_ean))
.routes(routes!(routes::book::get_book_by_id))
.routes(routes!(routes::book::create_book))
// Book Instance API
.routes(routes!(routes::book_instance::get_book_instance_by_id))
.routes(routes!(routes::book_instance::create_book_instance))
.routes(routes!(routes::book_instance::update_book_instance))
.routes(routes!(routes::book_instance::sell_book_instance))
.routes(routes!(routes::book_instance::bulk_create_book_instance))
.routes(routes!(routes::book_instance::get_bal_owner_book_instances))
.routes(routes!(routes::book_instance::get_bal_book_instances_by_ean))
.routes(routes!(routes::book_instance::search_bal_book_instances))
// Owner API
.routes(routes!(routes::owner::get_owner_by_id))
.routes(routes!(routes::owner::create_owner))
.routes(routes!(routes::owner::update_owner))
.routes(routes!(routes::owner::get_owners))
// Bal API
.routes(routes!(routes::bal::get_bal_by_id))
.routes(routes!(routes::bal::create_bal))
.routes(routes!(routes::bal::update_bal))
.routes(routes!(routes::bal::get_bals))
.routes(routes!(routes::bal::get_current_bal))
.routes(routes!(routes::bal::set_current_bal))
// Authentication
.route_layer(middleware::from_fn_with_state(shared_state.clone(), routes::auth::auth_middleware))
.routes(routes!(routes::auth::auth))
.routes(routes!(routes::auth::check_token))
// Misc
.routes(routes!(routes::misc::current_api_version))
.routes(routes!(routes::websocket::ws_handler))
.with_state(shared_state.clone());
let (router, mut api) = OpenApiRouter::new()
.nest("/api", open_api_router)
.route("/", get(index)) // temporary index page, will redirect/proxy to flutter app
.with_state(shared_state)
.split_for_parts();
api.info = InfoBuilder::new()
.title("Alexandria")
.description(Some("Alexandria is a server that manages books and users for Union Étudiante's book exchange"))
.contact(Some(ContactBuilder::new()
.url(Some("https://ueauvergne.fr"))
.name(Some("Union Étudiante Auvergne"))
.email(Some("unionetudianteauvergne@gmail.com"))
.build()))
.license(Some(LicenseBuilder::new().name("MIT").url(Some("https://spdx.org/licenses/MIT.html")).build()))
.version("1.0.0")
.build();
api.merge(ApiDoc::openapi());
let swagger = SwaggerUi::new("/docs/")
.url("/docs/openapi.json", api)
.config(Config::default()
.try_it_out_enabled(true)
.filter(true)
.display_request_duration(true)
.persist_authorization(true)
);
let router = router.merge(swagger);
let listener = tokio::net::TcpListener::bind(SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port)).await.unwrap();
log::info!("Running on http://{}", listener.local_addr().unwrap());
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>()
).await.unwrap();
}

View file

@ -7,9 +7,9 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{entities::user, AppState, Commands, CLI, KEYS};
use crate::{entities::user, AppState, KEYS};
//const TOKEN_EXPIRY_TIME: u64 = 15_778_476; // 6 Months
pub const DEFAULT_TOKEN_EXPIRY_TIME: u64 = 15_778_476; // 6 Months
pub async fn auth_middleware(
_claims: Claims,
@ -44,15 +44,14 @@ pub async fn auth(State(state): State<Arc<AppState>>, Json(payload): Json<AuthPa
match user::Entity::find().filter(user::Column::Username.eq(payload.username)).one(state.db_conn.as_ref()).await {
Err(_) | Ok(None) => return Err(AuthError::WrongCredentials),
Ok(Some(user)) => {
user.verify_password(payload.password);
if !user.verify_password(payload.password) {
return Err(AuthError::WrongCredentials);
};
let unix_timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time should go forward").as_secs();
let claims = Claims {
sub: user.username,
exp: unix_timestamp + match CLI.command {
Commands::Run { token_expiration_time, .. } => token_expiration_time,
_ => panic!("The auth endpoint cannot be used outside of a Run command")
},
exp: unix_timestamp + DEFAULT_TOKEN_EXPIRY_TIME,
user_id: user.id
};
let token = encode(&Header::default(), &claims, &KEYS.encoding)
@ -63,6 +62,10 @@ pub async fn auth(State(state): State<Arc<AppState>>, Json(payload): Json<AuthPa
}
}
pub fn generate_token_from_claims(claims: Claims) -> String {
encode(&Header::default(), &claims, &KEYS.encoding).unwrap()
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct TokenPayload {
token: String
@ -152,15 +155,15 @@ impl Keys {
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
sub: String,
exp: u64,
pub sub: String,
pub exp: u64,
pub user_id: u32,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct AuthBody {
access_token: String,
token_type: String,
pub access_token: String,
pub token_type: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]

View file

@ -1,3 +1,5 @@
use argon2::{Argon2, PasswordHasher};
use password_hash::{rand_core::OsRng, SaltString};
use sea_orm::ConnectionTrait;
use crate::entities::prelude::*;
@ -40,3 +42,8 @@ where C: ConnectionTrait,
None => false
}
}
pub fn hash_password(password: String) -> String {
let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(&password.clone().into_bytes(), &salt).unwrap().to_string()
}

View file

@ -1,11 +1,9 @@
use std::{fmt::Display, sync::Arc};
use argon2::{password_hash::{SaltString}, Argon2, PasswordHasher};
use inquire::{min_length, prompt_text, Confirm, Password, Select, Text};
use password_hash::rand_core::OsRng;
use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter};
use crate::entities::{owner, prelude::User, user::{self, ActiveModel}};
use crate::{entities::{owner, prelude::User, user::{self, ActiveModel}}, utils::auth::hash_password};
#[derive(Debug, Copy, Clone)]
enum Action {
@ -146,11 +144,6 @@ pub async fn manage_users(db: Arc<DatabaseConnection>) {
}
}
fn hash_password(password: String) -> String {
let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(&password.clone().into_bytes(), &salt).unwrap().to_string()
}
async fn select_user(db: Arc<DatabaseConnection>) -> Option<user::Model> {
let users = User::find().all(db.as_ref()).await.unwrap();
if users.is_empty() {