Initial commit: Book management system with Rust Loco backend and Vue 3 frontend

This commit is contained in:
2026-04-30 00:26:47 +08:00
commit 1f22546766
11332 changed files with 1076475 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
[alias]
loco = "run --"
loco-tool = "run --bin tool --"
playground = "run --example playground"
# https://github.com/rust-lang/rust/issues/141626
# (can be removed once link.exe is fixed)
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
+102
View File
@@ -0,0 +1,102 @@
name: CI
on:
push:
branches:
- master
- main
pull_request:
env:
RUST_TOOLCHAIN: stable
TOOLCHAIN_PROFILE: minimal
jobs:
rustfmt:
name: Check Style
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the code
uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: rustfmt
- name: Run cargo fmt
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
clippy:
name: Run Clippy
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the code
uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Run cargo clippy
uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms
test:
name: Run Tests
runs-on: ubuntu-latest
permissions:
contents: read
services:
redis:
image: redis
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- "6379:6379"
postgres:
image: postgres
env:
POSTGRES_DB: postgres_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
# Set health checks to wait until postgres has started
options: --health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout the code
uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Run cargo test
uses: actions-rs/cargo@v1
with:
command: test
args: --all-features --all
env:
REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}}
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test
+20
View File
@@ -0,0 +1,20 @@
**/config/local.yaml
**/config/*.local.yaml
**/config/production.yaml
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# include cargo lock
!Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
*.sqlite
*.sqlite-*
+2
View File
@@ -0,0 +1,2 @@
max_width = 100
use_small_heuristics = "Default"
+5817
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
[workspace]
[package]
name = "book-manager"
version = "0.1.0"
edition = "2021"
publish = false
default-run = "book_manager-cli"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace.dependencies]
loco-rs = { version = "0.16" }
[dependencies]
loco-rs = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
tokio = { version = "1.45", default-features = false, features = [
"rt-multi-thread",
] }
async-trait = { version = "0.1" }
axum = { version = "0.8" }
tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
regex = { version = "1.11" }
migration = { path = "migration" }
sea-orm = { version = "1.1", features = [
"sqlx-sqlite",
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
chrono = { version = "0.4" }
validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" }
[[bin]]
name = "book_manager-cli"
path = "src/bin/main.rs"
required-features = []
[[bin]]
name = "tool"
path = "src/bin/tool.rs"
required-features = []
[dev-dependencies]
loco-rs = { workspace = true, features = ["testing"] }
serial_test = { version = "3.1.1" }
rstest = { version = "0.25" }
insta = { version = "1.34", features = ["redactions", "yaml", "filters"] }
+58
View File
@@ -0,0 +1,58 @@
# Welcome to Loco :train:
[Loco](https://loco.rs) is a web and API framework running on Rust.
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
## Quick Start
```sh
cargo loco start
```
```sh
$ cargo loco start
Finished dev [unoptimized + debuginfo] target(s) in 21.63s
Running `target/debug/myapp start`
:
:
:
controller/app_routes.rs:203: [Middleware] Adding log trace id
▄ ▀
▀ ▄
▄ ▀ ▄ ▄ ▄▀
▄ ▀▄▄
▄ ▀ ▀ ▀▄▀█▄
▀█▄
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
██████ █████ ███ █████ ███ █████ ███ ▀█
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
██████ █████ ███ █████ █████ ███ ████▄
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
██████ █████ ███ ████ ███ █████ ███ ████▀
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
https://loco.rs
environment: development
database: automigrate
logger: debug
compilation: debug
modes: server
listening on http://localhost:5150
```
## Full Stack Serving
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
## Getting help
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
+50
View File
@@ -0,0 +1,50 @@
# 运行测试
## 运行所有测试
```bash
cargo test
```
## 运行特定测试模块
### 运行 books API 测试
```bash
cargo test books
```
### 运行 auth API 测试
```bash
cargo test auth
```
## 运行单个测试
```bash
cargo test can_list_books_with_pagination
```
## 显示测试输出
```bash
cargo test -- --nocapture
```
## 测试覆盖的接口
### Books API
- ✅ GET /api/books - 获取图书列表(支持分页)
- ✅ GET /api/books/:id - 获取单本图书
- ✅ POST /api/books - 创建图书(需要认证)
- ✅ PUT /api/books/:id - 更新图书(需要认证)
- ✅ DELETE /api/books/:id - 删除图书(需要认证)
### Auth API
- ✅ POST /api/auth/register - 用户注册
- ✅ POST /api/auth/login - 用户登录
- ✅ POST /api/auth/verify - 验证邮箱
## 测试说明
所有测试使用独立的测试数据库,不会影响开发环境的数据。
测试会自动清理创建的测试数据。
+20
View File
@@ -0,0 +1,20 @@
use sea_orm::Database;
#[tokio::main]
async fn main() {
let db = Database::connect("sqlite:book-manager_development.sqlite")
.await
.expect("Failed to connect to database");
let result: Vec<serde_json::Value> = sea_orm::DatabaseConnection::query_all(
&db,
sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"SELECT COUNT(*) as count FROM books".to_string(),
),
)
.await
.expect("Failed to query");
println!("Books count: {:?}", result);
}
+91
View File
@@ -0,0 +1,91 @@
# Loco configuration file documentation
# Application logging configuration
logger:
# Enable or disable logging.
enable: true
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
pretty_backtrace: true
# Log level, options: trace, debug, info, warn or error.
level: debug
# Define the logging format. options: compact, pretty or json
format: compact
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
# override_filter: trace
# Web server configuration
server:
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
port: 5150
# Binding for the server (which interface to bind to)
binding: localhost
# The UI hostname or IP address that mailers will point to.
host: http://localhost
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
middlewares:
# Worker Configuration
workers:
# specifies the worker mode. Options:
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
mode: BackgroundAsync
# Mailer Configuration.
mailer:
# SMTP mailer configuration.
smtp:
# Enable/Disable smtp mailer.
enable: true
# SMTP server host. e.x localhost, smtp.gmail.com
host: localhost
# SMTP server port
port: 1025
# Use secure connection (SSL/TLS).
secure: false
# auth:
# user:
# password:
# Override the SMTP hello name (default is the machine's hostname)
# hello_name:
# Initializers Configuration
# initializers:
# oauth2:
# authorization_code: # Authorization code grant type
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
# ... other fields
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="sqlite://book-manager_development.sqlite?mode=rwc") }}
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
# Set the idle duration before closing a connection.
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
# Minimum number of connections for a pool.
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
# Maximum number of connections for a pool.
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
# Run migration up when application loaded
auto_migrate: true
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
dangerously_truncate: false
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
dangerously_recreate: false
# Authentication Configuration
auth:
# JWT authentication
jwt:
# Secret key for token generation and verification
secret: jQX47pLKNrHcYHNXY9AY
# Token expiration time in seconds
expiration: 604800 # 7 days
+88
View File
@@ -0,0 +1,88 @@
# Loco configuration file documentation
# Application logging configuration
logger:
# Enable or disable logging.
enable: false
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
pretty_backtrace: true
# Log level, options: trace, debug, info, warn or error.
level: debug
# Define the logging format. options: compact, pretty or json
format: compact
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
# override_filter: trace
# Web server configuration
server:
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
port: 5150
# The UI hostname or IP address that mailers will point to.
host: http://localhost
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
middlewares:
# Worker Configuration
workers:
# specifies the worker mode. Options:
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
mode: ForegroundBlocking
# Mailer Configuration.
mailer:
stub: true
# SMTP mailer configuration.
smtp:
# Enable/Disable smtp mailer.
enable: true
# SMTP server host. e.x localhost, smtp.gmail.com
host: localhost
# SMTP server port
port: 1025
# Use secure connection (SSL/TLS).
secure: false
# auth:
# user:
# password:
# Initializers Configuration
# initializers:
# oauth2:
# authorization_code: # Authorization code grant type
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
# ... other fields
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="sqlite://book-manager_test.sqlite?mode=rwc") }}
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
# Set the idle duration before closing a connection.
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
# Minimum number of connections for a pool.
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
# Maximum number of connections for a pool.
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
# Run migration up when application loaded
auto_migrate: true
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
dangerously_truncate: true
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
dangerously_recreate: true
# Authentication Configuration
auth:
# JWT authentication
jwt:
# Secret key for token generation and verification
secret: 59UIggkMx8A8x1Y3yO1v
# Token expiration time in seconds
expiration: 604800 # 7 days
+21
View File
@@ -0,0 +1,21 @@
use book_manager::app::App;
#[allow(unused_imports)]
use loco_rs::{cli::playground, prelude::*};
#[tokio::main]
async fn main() -> loco_rs::Result<()> {
let _ctx = playground::<App>().await?;
// let active_model: articles::ActiveModel = articles::ActiveModel {
// title: Set(Some("how to build apps in 3 steps".to_string())),
// content: Set(Some("use Loco: https://loco.rs".to_string())),
// ..Default::default()
// };
// active_model.insert(&ctx.db).await.unwrap();
// let res = articles::Entity::find().all(&ctx.db).await.unwrap();
// println!("{:?}", res);
println!("welcome to playground. edit me at `examples/playground.rs`");
Ok(())
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
loco-rs = { workspace = true }
[dependencies.sea-orm-migration]
version = "1.1.0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
]
+20
View File
@@ -0,0 +1,20 @@
#![allow(elided_lifetimes_in_paths)]
#![allow(clippy::wildcard_imports)]
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_users;
mod m20250428_000001_create_books;
mod m20250428_000002_create_reading_records;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_users::Migration),
Box::new(m20250428_000001_create_books::Migration),
Box::new(m20250428_000002_create_reading_records::Migration),
// inject-above (do not remove this comment)
]
}
}
@@ -0,0 +1,41 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(
m,
"users",
&[
("id", ColType::PkAuto),
("pid", ColType::Uuid),
("email", ColType::StringUniq),
("password", ColType::String),
("api_key", ColType::StringUniq),
("name", ColType::String),
("reset_token", ColType::StringNull),
("reset_sent_at", ColType::TimestampWithTimeZoneNull),
("email_verification_token", ColType::StringNull),
(
"email_verification_sent_at",
ColType::TimestampWithTimeZoneNull,
),
("email_verified_at", ColType::TimestampWithTimeZoneNull),
("magic_link_token", ColType::StringNull),
("magic_link_expiration", ColType::TimestampWithTimeZoneNull),
],
&[],
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "users").await?;
Ok(())
}
}
@@ -0,0 +1,31 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(
m,
"books",
&[
("id", ColType::PkAuto),
("title", ColType::String),
("author", ColType::String),
("description", ColType::TextNull),
("cover", ColType::StringNull),
("content", ColType::Text),
],
&[],
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "books").await?;
Ok(())
}
}
@@ -0,0 +1,31 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(
m,
"reading_records",
&[
("id", ColType::PkAuto),
("user_id", ColType::Integer),
("book_id", ColType::Integer),
("last_read_chapter", ColType::StringNull),
("last_read_position", ColType::IntegerNull),
("read_percentage", ColType::DoubleNull),
],
&[],
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "reading_records").await?;
Ok(())
}
}
+72
View File
@@ -0,0 +1,72 @@
use async_trait::async_trait;
use loco_rs::{
app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue},
boot::{create_app, BootResult, StartMode},
config::Config,
controller::AppRoutes,
db::{self, truncate_table},
environment::Environment,
task::Tasks,
Result,
};
use migration::Migrator;
use std::path::Path;
#[allow(unused_imports)]
use crate::{controllers, models::_entities::users, tasks, workers::downloader::DownloadWorker};
pub struct App;
#[async_trait]
impl Hooks for App {
fn app_name() -> &'static str {
env!("CARGO_CRATE_NAME")
}
fn app_version() -> String {
format!(
"{} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("BUILD_SHA")
.or(option_env!("GITHUB_SHA"))
.unwrap_or("dev")
)
}
async fn boot(
mode: StartMode,
environment: &Environment,
config: Config,
) -> Result<BootResult> {
create_app::<Self, Migrator>(mode, environment, config).await
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::books::routes())
.add_route(controllers::auth::routes())
.add_route(controllers::reading::routes())
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;
Ok(())
}
#[allow(unused_variables)]
fn register_tasks(tasks: &mut Tasks) {
// tasks-inject (do not remove)
}
async fn truncate(ctx: &AppContext) -> Result<()> {
truncate_table(&ctx.db, users::Entity).await?;
Ok(())
}
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
.await?;
Ok(())
}
}
+8
View File
@@ -0,0 +1,8 @@
use book_manager::app::App;
use loco_rs::cli;
use migration::Migrator;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {
cli::main::<App, Migrator>().await
}
+8
View File
@@ -0,0 +1,8 @@
use book_manager::app::App;
use loco_rs::cli;
use migration::Migrator;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {
cli::main::<App, Migrator>().await
}
+273
View File
@@ -0,0 +1,273 @@
use crate::{
mailers::auth::AuthMailer,
models::{
_entities::users,
users::{LoginParams, RegisterParams},
},
views::auth::{CurrentResponse, LoginResponse},
};
use loco_rs::prelude::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
fn get_allow_email_domain_re() -> &'static Regex {
EMAIL_DOMAIN_RE.get_or_init(|| {
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
})
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ForgotParams {
pub email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResetParams {
pub token: String,
pub password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct MagicLinkParams {
pub email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResendVerificationParams {
pub email: String,
}
/// Register function creates a new user with the given parameters and sends a
/// welcome email to the user
#[debug_handler]
async fn register(
State(ctx): State<AppContext>,
Json(params): Json<RegisterParams>,
) -> Result<Response> {
let res = users::Model::create_with_password(&ctx.db, &params).await;
let user = match res {
Ok(user) => user,
Err(err) => {
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return format::json(());
}
};
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
AuthMailer::send_welcome(&ctx, &user).await?;
format::json(())
}
/// Verify register user. if the user not verified his email, he can't login to
/// the system.
#[debug_handler]
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
return unauthorized("invalid token");
};
if user.email_verified_at.is_some() {
tracing::info!(pid = user.pid.to_string(), "user already verified");
} else {
let active_model = user.into_active_model();
let user = active_model.verified(&ctx.db).await?;
tracing::info!(pid = user.pid.to_string(), "user verified");
}
format::json(())
}
/// In case the user forgot his password this endpoints generate a forgot token
/// and send email to the user. In case the email not found in our DB, we are
/// returning a valid request for for security reasons (not exposing users DB
/// list).
#[debug_handler]
async fn forgot(
State(ctx): State<AppContext>,
Json(params): Json<ForgotParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
return format::json(());
};
let user = user
.into_active_model()
.set_forgot_password_sent(&ctx.db)
.await?;
AuthMailer::forgot_password(&ctx, &user).await?;
format::json(())
}
/// reset user password by the given parameters
#[debug_handler]
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, &params.token).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
tracing::info!("reset token not found");
return format::json(());
};
user.into_active_model()
.reset_password(&ctx.db, &params.password)
.await?;
format::json(())
}
/// Creates a user login and returns a token
#[debug_handler]
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
tracing::debug!(
email = params.email,
"login attempt with non-existent email"
);
return unauthorized("Invalid credentials!");
};
let valid = user.verify_password(&params.password);
if !valid {
return unauthorized("unauthorized!");
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::json(LoginResponse::new(&user, &token))
}
#[debug_handler]
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
}
/// Magic link authentication provides a secure and passwordless way to log in to the application.
///
/// # Flow
/// 1. **Request a Magic Link**:
/// A registered user sends a POST request to `/magic-link` with their email.
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
///
/// 2. **Click the Magic Link**:
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
/// If invalid or expired, an unauthorized response is returned.
///
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
async fn magic_link(
State(ctx): State<AppContext>,
Json(params): Json<MagicLinkParams>,
) -> Result<Response> {
let email_regex = get_allow_email_domain_re();
if !email_regex.is_match(&params.email) {
tracing::debug!(
email = params.email,
"The provided email is invalid or does not match the allowed domains"
);
return bad_request("invalid request");
}
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
tracing::debug!(email = params.email, "user not found by email");
return format::empty_json();
};
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
AuthMailer::send_magic_link(&ctx, &user).await?;
format::empty_json()
}
/// Verifies a magic link token and authenticates the user.
async fn magic_link_verify(
Path(token): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
return unauthorized("unauthorized!");
};
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::json(LoginResponse::new(&user, &token))
}
#[debug_handler]
async fn resend_verification_email(
State(ctx): State<AppContext>,
Json(params): Json<ResendVerificationParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
tracing::info!(
email = params.email,
"User not found for resend verification"
);
return format::json(());
};
if user.email_verified_at.is_some() {
tracing::info!(
pid = user.pid.to_string(),
"User already verified, skipping resend"
);
return format::json(());
}
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
AuthMailer::send_welcome(&ctx, &user).await?;
tracing::info!(pid = user.pid.to_string(), "Verification email re-sent");
format::json(())
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/auth")
.add("/register", post(register))
.add("/verify/{token}", get(verify))
.add("/login", post(login))
.add("/forgot", post(forgot))
.add("/reset", post(reset))
.add("/current", get(current))
.add("/magic-link", post(magic_link))
.add("/magic-link/{token}", get(magic_link_verify))
.add("/resend-verification-mail", post(resend_verification_email))
}
+115
View File
@@ -0,0 +1,115 @@
use axum::{debug_handler, routing::delete as axum_delete};
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::books::{CreateBookParams, Model as BookModel};
#[derive(Debug, Deserialize, Serialize)]
pub struct Params {
pub title: String,
pub author: String,
pub description: Option<String>,
pub cover: Option<String>,
pub content: String,
}
impl From<Params> for CreateBookParams {
fn from(params: Params) -> Self {
Self {
title: params.title,
author: params.author,
description: params.description,
cover: params.cover,
content: params.content,
}
}
}
/// Simple test endpoint
#[debug_handler]
pub async fn test(State(_ctx): State<AppContext>) -> Result<Response> {
format::json(serde_json::json!({"message": "Books controller is working!"}))
}
/// List all books with pagination
#[debug_handler]
pub async fn list(
Query(params): Query<PaginationParams>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(12);
let paginated_books = BookModel::list_paginated(&ctx.db, page, per_page).await?;
format::json(paginated_books)
}
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
pub page: Option<i32>,
pub per_page: Option<i32>,
}
/// Get a single book by ID
#[debug_handler]
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
let book = BookModel::find_by_id(&ctx.db, id).await?;
format::json(book)
}
/// Create a new book (requires authentication)
#[debug_handler]
pub async fn create(
State(ctx): State<AppContext>,
auth: auth::JWT,
Json(params): Json<Params>,
) -> Result<Response> {
// Only authenticated users can create books
let _user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid.to_string())
.await?;
let book_params: CreateBookParams = params.into();
let book = BookModel::create(&ctx.db, &book_params).await?;
format::json(book)
}
/// Update a book (requires authentication)
#[debug_handler]
pub async fn update(
Path(id): Path<i32>,
State(ctx): State<AppContext>,
auth: auth::JWT,
Json(params): Json<Params>,
) -> Result<Response> {
let _user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid.to_string())
.await?;
let book = BookModel::find_by_id(&ctx.db, id).await?;
let book_params: CreateBookParams = params.into();
let updated_book = book.update(&ctx.db, &book_params).await?;
format::json(updated_book)
}
/// Delete a book (requires authentication)
#[debug_handler]
pub async fn delete(
Path(id): Path<i32>,
State(ctx): State<AppContext>,
auth: auth::JWT,
) -> Result<Response> {
let _user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid.to_string())
.await?;
let book = BookModel::find_by_id(&ctx.db, id).await?;
book.delete(&ctx.db).await?;
format::empty()
}
pub fn routes() -> Routes {
Routes::new()
.prefix("books")
.add("/test", get(test))
.add("/", get(list))
.add("/", post(create))
.add("/{id}", get(get_one))
}
+3
View File
@@ -0,0 +1,3 @@
pub mod auth;
pub mod books;
pub mod reading;
+58
View File
@@ -0,0 +1,58 @@
use axum::debug_handler;
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::reading_records::{CreateReadingRecordParams, Model as ReadingRecordModel};
#[derive(Debug, Deserialize, Serialize)]
pub struct SaveRecordParams {
pub book_id: i32,
pub last_read_chapter: Option<String>,
pub last_read_position: Option<i32>,
pub read_percentage: Option<f64>,
}
/// Save or update reading record
#[debug_handler]
pub async fn save_record(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<SaveRecordParams>,
) -> Result<Response> {
let user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid.to_string())
.await?;
// Find or create the reading record
let mut record = ReadingRecordModel::find_or_create(&ctx.db, user.id, params.book_id).await?;
// Update the record
let update_params = CreateReadingRecordParams {
book_id: params.book_id,
last_read_chapter: params.last_read_chapter,
last_read_position: params.last_read_position,
read_percentage: params.read_percentage,
};
record = record.update_record(&ctx.db, &update_params).await?;
format::json(record)
}
/// Get user's reading history
#[debug_handler]
pub async fn history(
auth: auth::JWT,
State(ctx): State<AppContext>,
) -> Result<Response> {
let user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid.to_string())
.await?;
let records = ReadingRecordModel::list_by_user(&ctx.db, user.id).await?;
format::json(records)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("reading")
.add("/record", post(save_record))
.add("/history", get(history))
}
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -0,0 +1,17 @@
---
- id: 1
pid: 11111111-1111-1111-1111-111111111111
email: user1@example.com
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
name: user1
created_at: "2023-11-12T12:34:56.789Z"
updated_at: "2023-11-12T12:34:56.789Z"
- id: 2
pid: 22222222-2222-2222-2222-222222222222
email: user2@example.com
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
name: user2
created_at: "2023-11-12T12:34:56.789Z"
updated_at: "2023-11-12T12:34:56.789Z"
+1
View File
@@ -0,0 +1 @@
+9
View File
@@ -0,0 +1,9 @@
pub mod app;
pub mod controllers;
pub mod data;
pub mod initializers;
pub mod mailers;
pub mod models;
pub mod tasks;
pub mod views;
pub mod workers;
+90
View File
@@ -0,0 +1,90 @@
// auth mailer
#![allow(non_upper_case_globals)]
use loco_rs::prelude::*;
use serde_json::json;
use crate::models::users;
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link");
#[allow(clippy::module_name_repetitions)]
pub struct AuthMailer {}
impl Mailer for AuthMailer {}
impl AuthMailer {
/// Sending welcome email the the given user
///
/// # Errors
///
/// When email sending is failed
pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
Self::mail_template(
ctx,
&welcome,
mailer::Args {
to: user.email.to_string(),
locals: json!({
"name": user.name,
"verifyToken": user.email_verification_token,
"domain": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;
Ok(())
}
/// Sending forgot password email
///
/// # Errors
///
/// When email sending is failed
pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> {
Self::mail_template(
ctx,
&forgot,
mailer::Args {
to: user.email.to_string(),
locals: json!({
"name": user.name,
"resetToken": user.reset_token,
"domain": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;
Ok(())
}
/// Sends a magic link authentication email to the user.
///
/// # Errors
///
/// When email sending is failed
pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> {
Self::mail_template(
ctx,
&magic_link,
mailer::Args {
to: user.email.to_string(),
locals: json!({
"name": user.name,
"token": user.magic_link_token.clone().ok_or_else(|| Error::string(
"the user model not contains magic link token",
))?,
"host": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;
Ok(())
}
}
+11
View File
@@ -0,0 +1,11 @@
;<html>
<body>
Hey {{name}},
Forgot your password? No worries! You can reset it by clicking the link below:
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
If you didn't request a password reset, please ignore this email.
Best regards,<br>The Loco Team</br>
</body>
</html>
@@ -0,0 +1 @@
Your reset password link
+3
View File
@@ -0,0 +1,3 @@
Reset your password with this link:
http://localhost/reset#{{resetToken}}
@@ -0,0 +1,8 @@
;<html>
<body>
<p>Magic link example:</p>
<a href="{{host}}/api/auth/magic-link/{{token}}">
Verify Your Account
</a>
</body>
</html>
@@ -0,0 +1 @@
Magic link example
@@ -0,0 +1,2 @@
Magic link with this link:
{{host}}/api/auth/magic-link/{{token}}
+13
View File
@@ -0,0 +1,13 @@
;<html>
<body>
Dear {{name}},
Welcome to Loco! You can now log in to your account.
Before you get started, please verify your account by clicking the link below:
<a href="{{domain}}/api/auth/verify/{{verifyToken}}">
Verify Your Account
</a>
<p>Best regards,<br>The Loco Team</p>
</body>
</html>
@@ -0,0 +1 @@
Welcome {{name}}
+4
View File
@@ -0,0 +1,4 @@
Welcome {{name}}, you can now log in.
Verify your account with the link below:
{{domain}}/api/auth/verify/{{verifyToken}}
+1
View File
@@ -0,0 +1 @@
pub mod auth;
+21
View File
@@ -0,0 +1,21 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "books")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub author: String,
pub description: Option<String>,
pub cover: Option<String>,
pub content: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+6
View File
@@ -0,0 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub mod prelude;
pub mod users;
pub mod books;
pub mod reading_records;
+4
View File
@@ -0,0 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub use super::users::Entity as Users;
pub use super::books::Entity as Books;
pub use super::reading_records::Entity as ReadingRecords;
@@ -0,0 +1,21 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "reading_records")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub book_id: i32,
pub last_read_chapter: Option<String>,
pub last_read_position: Option<i32>,
pub read_percentage: Option<f64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+30
View File
@@ -0,0 +1,30 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub pid: Uuid,
#[sea_orm(unique)]
pub email: String,
pub password: String,
#[sea_orm(unique)]
pub api_key: String,
pub name: String,
pub reset_token: Option<String>,
pub reset_sent_at: Option<DateTimeWithTimeZone>,
pub email_verification_token: Option<String>,
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
pub email_verified_at: Option<DateTimeWithTimeZone>,
pub magic_link_token: Option<String>,
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
+98
View File
@@ -0,0 +1,98 @@
use loco_rs::prelude::*;
use sea_orm::{QueryOrder, QuerySelect, PaginatorTrait};
use serde::{Deserialize, Serialize};
pub use super::_entities::books::{self, ActiveModel, Entity, Model};
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateBookParams {
pub title: String,
pub author: String,
pub description: Option<String>,
pub cover: Option<String>,
pub content: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PaginatedBooks {
pub books: Vec<Model>,
pub total: u64,
pub page: i32,
pub per_page: i32,
pub total_pages: i32,
}
impl Model {
/// Find all books with pagination
pub async fn list_paginated(
db: &DatabaseConnection,
page: i32,
per_page: i32,
) -> ModelResult<PaginatedBooks> {
let total = books::Entity::find().count(db).await?;
let total_pages = ((total as f64) / (per_page as f64)).ceil() as i32;
let books = books::Entity::find()
.order_by_desc(books::Column::Id)
.limit(per_page as u64)
.offset(((page - 1) * per_page) as u64)
.all(db)
.await?;
Ok(PaginatedBooks {
books,
total,
page,
per_page,
total_pages,
})
}
/// Find all books (legacy, for backward compatibility)
pub async fn list(db: &DatabaseConnection) -> ModelResult<Vec<Model>> {
let books = books::Entity::find()
.order_by_desc(books::Column::Id)
.all(db)
.await?;
Ok(books)
}
/// Find a book by ID
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> ModelResult<Self> {
let book = books::Entity::find_by_id(id).one(db).await?;
book.ok_or_else(|| ModelError::EntityNotFound)
}
/// Create a new book
pub async fn create(db: &DatabaseConnection, params: &CreateBookParams) -> ModelResult<Self> {
let book = books::ActiveModel {
title: ActiveValue::Set(params.title.clone()),
author: ActiveValue::Set(params.author.clone()),
description: ActiveValue::Set(params.description.clone()),
cover: ActiveValue::Set(params.cover.clone()),
content: ActiveValue::Set(params.content.clone()),
..Default::default()
};
let book = book.insert(db).await?;
Ok(book)
}
/// Update a book
pub async fn update(&self, db: &DatabaseConnection, params: &CreateBookParams) -> ModelResult<Self> {
let mut book: books::ActiveModel = self.clone().into();
book.title = ActiveValue::Set(params.title.clone());
book.author = ActiveValue::Set(params.author.clone());
book.description = ActiveValue::Set(params.description.clone());
book.cover = ActiveValue::Set(params.cover.clone());
book.content = ActiveValue::Set(params.content.clone());
let book = book.update(db).await?;
Ok(book)
}
/// Delete a book
pub async fn delete(&self, db: &DatabaseConnection) -> ModelResult<()> {
let book: books::ActiveModel = self.clone().into();
book.delete(db).await?;
Ok(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod _entities;
pub mod users;
pub mod books;
pub mod reading_records;
+66
View File
@@ -0,0 +1,66 @@
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use serde::{Deserialize, Serialize};
pub use super::_entities::reading_records::{self, ActiveModel, Entity, Model};
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateReadingRecordParams {
pub book_id: i32,
pub last_read_chapter: Option<String>,
pub last_read_position: Option<i32>,
pub read_percentage: Option<f64>,
}
impl Model {
/// Find reading records by user ID
pub async fn list_by_user(db: &DatabaseConnection, user_id: i32) -> ModelResult<Vec<Model>> {
let records = reading_records::Entity::find()
.filter(reading_records::Column::UserId.eq(user_id))
.order_by_desc(reading_records::Column::Id)
.all(db)
.await?;
Ok(records)
}
/// Find or create a reading record for a user and book
pub async fn find_or_create(
db: &DatabaseConnection,
user_id: i32,
book_id: i32,
) -> ModelResult<Self> {
// Try to find existing record
let record = reading_records::Entity::find()
.filter(reading_records::Column::UserId.eq(user_id))
.filter(reading_records::Column::BookId.eq(book_id))
.one(db)
.await?;
if let Some(record) = record {
return Ok(record);
}
// Create new record
let record = reading_records::ActiveModel {
user_id: ActiveValue::Set(user_id),
book_id: ActiveValue::Set(book_id),
..Default::default()
};
let record = record.insert(db).await?;
Ok(record)
}
/// Update a reading record
pub async fn update_record(
&self,
db: &DatabaseConnection,
params: &CreateReadingRecordParams,
) -> ModelResult<Self> {
let mut record: reading_records::ActiveModel = self.clone().into();
record.last_read_chapter = ActiveValue::Set(params.last_read_chapter.clone());
record.last_read_position = ActiveValue::Set(params.last_read_position);
record.read_percentage = ActiveValue::Set(params.read_percentage);
let record = record.update(db).await?;
Ok(record)
}
}
+369
View File
@@ -0,0 +1,369 @@
use async_trait::async_trait;
use chrono::{offset::Local, Duration};
use loco_rs::{auth::jwt, hash, prelude::*};
use serde::{Deserialize, Serialize};
use serde_json::Map;
use uuid::Uuid;
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
pub const MAGIC_LINK_LENGTH: i8 = 32;
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginParams {
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterParams {
pub email: String,
pub password: String,
pub name: String,
}
#[derive(Debug, Validate, Deserialize)]
pub struct Validator {
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
pub name: String,
#[validate(email(message = "invalid email"))]
pub email: String,
}
impl Validatable for ActiveModel {
fn validator(&self) -> Box<dyn Validate> {
Box::new(Validator {
name: self.name.as_ref().to_owned(),
email: self.email.as_ref().to_owned(),
})
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
self.validate()?;
if insert {
let mut this = self;
this.pid = ActiveValue::Set(Uuid::new_v4());
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
Ok(this)
} else {
Ok(self)
}
}
}
#[async_trait]
impl Authenticable for Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::ApiKey, api_key)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
Self::find_by_pid(db, claims_key).await
}
}
impl Model {
/// finds a user by the provided email
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Email, email)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the provided verification token
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_verification_token(
db: &DatabaseConnection,
token: &str,
) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::EmailVerificationToken, token)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the magic token and verify and token expiration
///
/// # Errors
///
/// When could not find user by the given token or DB query error ot token expired
pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
query::condition()
.eq(users::Column::MagicLinkToken, token)
.build(),
)
.one(db)
.await?;
let user = user.ok_or_else(|| ModelError::EntityNotFound)?;
if let Some(expired_at) = user.magic_link_expiration {
if expired_at >= Local::now() {
Ok(user)
} else {
tracing::debug!(
user_pid = user.pid.to_string(),
token_expiration = expired_at.to_string(),
"magic token expired for the user."
);
Err(ModelError::msg("magic token expired"))
}
} else {
tracing::error!(
user_pid = user.pid.to_string(),
"magic link expiration time not exists"
);
Err(ModelError::msg("expiration token not exists"))
}
}
/// finds a user by the provided reset token
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::ResetToken, token)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the provided pid
///
/// # Errors
///
/// When could not find user or DB query error
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?;
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Pid, parse_uuid)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the provided api key
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::ApiKey, api_key)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// Verifies whether the provided plain password matches the hashed password
///
/// # Errors
///
/// when could not verify password
#[must_use]
pub fn verify_password(&self, password: &str) -> bool {
hash::verify_password(password, &self.password)
}
/// Asynchronously creates a user with a password and saves it to the
/// database.
///
/// # Errors
///
/// When could not save the user into the DB
pub async fn create_with_password(
db: &DatabaseConnection,
params: &RegisterParams,
) -> ModelResult<Self> {
let txn = db.begin().await?;
if users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Email, &params.email)
.build(),
)
.one(&txn)
.await?
.is_some()
{
return Err(ModelError::EntityAlreadyExists {});
}
let password_hash =
hash::hash_password(&params.password).map_err(|e| ModelError::Any(e.into()))?;
let user = users::ActiveModel {
email: ActiveValue::set(params.email.to_string()),
password: ActiveValue::set(password_hash),
name: ActiveValue::set(params.name.to_string()),
..Default::default()
}
.insert(&txn)
.await?;
txn.commit().await?;
Ok(user)
}
/// Creates a JWT
///
/// # Errors
///
/// when could not convert user claims to jwt token
pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult<String> {
jwt::JWT::new(secret)
.generate_token(expiration, self.pid.to_string(), Map::new())
.map_err(ModelError::from)
}
}
impl ActiveModel {
/// Sets the email verification information for the user and
/// updates it in the database.
///
/// This method is used to record the timestamp when the email verification
/// was sent and generate a unique verification token for the user.
///
/// # Errors
///
/// when has DB query error
pub async fn set_email_verification_sent(
mut self,
db: &DatabaseConnection,
) -> ModelResult<Model> {
self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into()));
self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
self.update(db).await.map_err(ModelError::from)
}
/// Sets the information for a reset password request,
/// generates a unique reset password token, and updates it in the
/// database.
///
/// This method records the timestamp when the reset password token is sent
/// and generates a unique token for the user.
///
/// # Arguments
///
/// # Errors
///
/// when has DB query error
pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.reset_sent_at = ActiveValue::set(Some(Local::now().into()));
self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
self.update(db).await.map_err(ModelError::from)
}
/// Records the verification time when a user verifies their
/// email and updates it in the database.
///
/// This method sets the timestamp when the user successfully verifies their
/// email.
///
/// # Errors
///
/// when has DB query error
pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.email_verified_at = ActiveValue::set(Some(Local::now().into()));
self.update(db).await.map_err(ModelError::from)
}
/// Resets the current user password with a new password and
/// updates it in the database.
///
/// This method hashes the provided password and sets it as the new password
/// for the user.
///
/// # Errors
///
/// when has DB query error or could not hashed the given password
pub async fn reset_password(
mut self,
db: &DatabaseConnection,
password: &str,
) -> ModelResult<Model> {
self.password =
ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?);
self.reset_token = ActiveValue::Set(None);
self.reset_sent_at = ActiveValue::Set(None);
self.update(db).await.map_err(ModelError::from)
}
/// Creates a magic link token for passwordless authentication.
///
/// Generates a random token with a specified length and sets an expiration time
/// for the magic link. This method is used to initiate the magic link authentication flow.
///
/// # Errors
/// - Returns an error if database update fails
pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize);
let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into());
self.magic_link_token = ActiveValue::set(Some(random_str));
self.magic_link_expiration = ActiveValue::set(Some(expired.into()));
self.update(db).await.map_err(ModelError::from)
}
/// Verifies and invalidates the magic link after successful authentication.
///
/// Clears the magic link token and expiration time after the user has
/// successfully authenticated using the magic link.
///
/// # Errors
/// - Returns an error if database update fails
pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.magic_link_token = ActiveValue::set(None);
self.magic_link_expiration = ActiveValue::set(None);
self.update(db).await.map_err(ModelError::from)
}
}
+1
View File
@@ -0,0 +1 @@
+41
View File
@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
use crate::models::_entities::users;
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginResponse {
pub token: String,
pub pid: String,
pub name: String,
pub is_verified: bool,
}
impl LoginResponse {
#[must_use]
pub fn new(user: &users::Model, token: &String) -> Self {
Self {
token: token.to_string(),
pid: user.pid.to_string(),
name: user.name.clone(),
is_verified: user.email_verified_at.is_some(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CurrentResponse {
pub pid: String,
pub name: String,
pub email: String,
}
impl CurrentResponse {
#[must_use]
pub fn new(user: &users::Model) -> Self {
Self {
pid: user.pid.to_string(),
name: user.name.clone(),
email: user.email.clone(),
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod auth;
+23
View File
@@ -0,0 +1,23 @@
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
pub struct DownloadWorker {
pub ctx: AppContext,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct DownloadWorkerArgs {
pub user_guid: String,
}
#[async_trait]
impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> {
// TODO: Some actual work goes here...
Ok(())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod downloader;
+4
View File
@@ -0,0 +1,4 @@
mod models;
mod requests;
mod tasks;
mod workers;
+1
View File
@@ -0,0 +1 @@
mod users;
@@ -0,0 +1,23 @@
---
source: tests/models/users.rs
expression: res
---
Ok(
Model {
created_at: DATE,
updated_at: DATE,
id: ID
pid: PID,
email: "test@framework.com",
password: "PASSWORD",
api_key: "lo-PID",
name: "framework",
reset_token: None,
reset_sent_at: None,
email_verification_token: None,
email_verification_sent_at: None,
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
@@ -0,0 +1,7 @@
---
source: tests/models/users.rs
expression: non_existing_user_results
---
Err(
EntityNotFound,
)
@@ -0,0 +1,23 @@
---
source: tests/models/users.rs
expression: existing_user
---
Ok(
Model {
created_at: 2023-11-12T12:34:56.789+00:00,
updated_at: 2023-11-12T12:34:56.789+00:00,
id: 1,
pid: 11111111-1111-1111-1111-111111111111,
email: "user1@example.com",
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
name: "user1",
reset_token: None,
reset_sent_at: None,
email_verification_token: None,
email_verification_sent_at: None,
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
@@ -0,0 +1,7 @@
---
source: tests/models/users.rs
expression: non_existing_user_results
---
Err(
EntityNotFound,
)
@@ -0,0 +1,23 @@
---
source: tests/models/users.rs
expression: existing_user
---
Ok(
Model {
created_at: 2023-11-12T12:34:56.789+00:00,
updated_at: 2023-11-12T12:34:56.789+00:00,
id: 1,
pid: 11111111-1111-1111-1111-111111111111,
email: "user1@example.com",
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
name: "user1",
reset_token: None,
reset_sent_at: None,
email_verification_token: None,
email_verification_sent_at: None,
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
@@ -0,0 +1,9 @@
---
source: tests/models/users.rs
expression: res
---
Err(
Custom(
"{\"email\":[{\"code\":\"email\",\"message\":\"invalid email\"}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}",
),
)
@@ -0,0 +1,7 @@
---
source: tests/models/users.rs
expression: new_user
---
Err(
EntityAlreadyExists,
)
+360
View File
@@ -0,0 +1,360 @@
use book_manager::{
app::App,
models::users::{self, Model, RegisterParams},
};
use chrono::{offset::Local, Duration};
use insta::assert_debug_snapshot;
use loco_rs::testing::prelude::*;
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
use serial_test::serial;
macro_rules! configure_insta {
($($expr:expr),*) => {
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
settings.set_snapshot_suffix("users");
let _guard = settings.bind_to_scope();
};
}
#[tokio::test]
#[serial]
async fn test_can_validate_model() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
let invalid_user = users::ActiveModel {
name: ActiveValue::set("1".to_string()),
email: ActiveValue::set("invalid-email".to_string()),
..Default::default()
};
let res = invalid_user.insert(&boot.app_context.db).await;
assert_debug_snapshot!(res);
}
#[tokio::test]
#[serial]
async fn can_create_with_password() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
let params = RegisterParams {
email: "test@framework.com".to_string(),
password: "1234".to_string(),
name: "framework".to_string(),
};
let res = Model::create_with_password(&boot.app_context.db, &params).await;
insta::with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!(res);
});
}
#[tokio::test]
#[serial]
async fn handle_create_with_password_with_duplicate() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let new_user = Model::create_with_password(
&boot.app_context.db,
&RegisterParams {
email: "user1@example.com".to_string(),
password: "1234".to_string(),
name: "framework".to_string(),
},
)
.await;
assert_debug_snapshot!(new_user);
}
#[tokio::test]
#[serial]
async fn can_find_by_email() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await;
let non_existing_user_results =
Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await;
assert_debug_snapshot!(existing_user);
assert_debug_snapshot!(non_existing_user_results);
}
#[tokio::test]
#[serial]
async fn can_find_by_pid() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let existing_user =
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await;
let non_existing_user_results =
Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await;
assert_debug_snapshot!(existing_user);
assert_debug_snapshot!(non_existing_user_results);
}
#[tokio::test]
#[serial]
async fn can_verification_token() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID");
assert!(
user.email_verification_sent_at.is_none(),
"Expected no email verification sent timestamp"
);
assert!(
user.email_verification_token.is_none(),
"Expected no email verification token"
);
let result = user
.into_active_model()
.set_email_verification_sent(&boot.app_context.db)
.await;
assert!(result.is_ok(), "Failed to set email verification sent");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID after setting verification sent");
assert!(
user.email_verification_sent_at.is_some(),
"Expected email verification sent timestamp to be present"
);
assert!(
user.email_verification_token.is_some(),
"Expected email verification token to be present"
);
}
#[tokio::test]
#[serial]
async fn can_set_forgot_password_sent() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID");
assert!(
user.reset_sent_at.is_none(),
"Expected no reset sent timestamp"
);
assert!(user.reset_token.is_none(), "Expected no reset token");
let result = user
.into_active_model()
.set_forgot_password_sent(&boot.app_context.db)
.await;
assert!(result.is_ok(), "Failed to set forgot password sent");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID after setting forgot password sent");
assert!(
user.reset_sent_at.is_some(),
"Expected reset sent timestamp to be present"
);
assert!(
user.reset_token.is_some(),
"Expected reset token to be present"
);
}
#[tokio::test]
#[serial]
async fn can_verified() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID");
assert!(
user.email_verified_at.is_none(),
"Expected email to be unverified"
);
let result = user
.into_active_model()
.verified(&boot.app_context.db)
.await;
assert!(result.is_ok(), "Failed to mark email as verified");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID after verification");
assert!(
user.email_verified_at.is_some(),
"Expected email to be verified"
);
}
#[tokio::test]
#[serial]
async fn can_reset_password() {
configure_insta!();
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
seed::<App>(&boot.app_context)
.await
.expect("Failed to seed database");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID");
assert!(
user.verify_password("12341234"),
"Password verification failed for original password"
);
let result = user
.clone()
.into_active_model()
.reset_password(&boot.app_context.db, "new-password")
.await;
assert!(result.is_ok(), "Failed to reset password");
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to find user by PID after password reset");
assert!(
user.verify_password("new-password"),
"Password verification failed for new password"
);
}
#[tokio::test]
#[serial]
async fn magic_link() {
let boot = boot_test::<App>().await.unwrap();
seed::<App>(&boot.app_context).await.unwrap();
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(
user.magic_link_token.is_none(),
"Magic link token should be initially unset"
);
assert!(
user.magic_link_expiration.is_none(),
"Magic link expiration should be initially unset"
);
let create_result = user
.into_active_model()
.create_magic_link(&boot.app_context.db)
.await;
assert!(
create_result.is_ok(),
"Failed to create magic link: {:?}",
create_result.unwrap_err()
);
let updated_user =
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to refetch user after magic link creation");
assert!(
updated_user.magic_link_token.is_some(),
"Magic link token should be set after creation"
);
let magic_link_token = updated_user.magic_link_token.unwrap();
assert_eq!(
magic_link_token.len(),
users::MAGIC_LINK_LENGTH as usize,
"Magic link token length does not match expected length"
);
assert!(
updated_user.magic_link_expiration.is_some(),
"Magic link expiration should be set after creation"
);
let now = Local::now();
let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into());
let actual_expiration = updated_user.magic_link_expiration.unwrap();
assert!(
actual_expiration >= now,
"Magic link expiration should be in the future or now"
);
assert!(
actual_expiration <= should_expired_at,
"Magic link expiration exceeds expected maximum expiration time"
);
}
+502
View File
@@ -0,0 +1,502 @@
use book_manager::{app::App, models::users};
use insta::{assert_debug_snapshot, with_settings};
use loco_rs::testing::prelude::*;
use rstest::rstest;
use serial_test::serial;
use super::prepare_data;
// TODO: see how to dedup / extract this to app-local test utils
// not to framework, because that would require a runtime dep on insta
macro_rules! configure_insta {
($($expr:expr),*) => {
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
settings.set_snapshot_suffix("auth_request");
let _guard = settings.bind_to_scope();
};
}
#[tokio::test]
#[serial]
async fn can_register() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
let email = "test@loco.com";
let payload = serde_json::json!({
"name": "loco",
"email": email,
"password": "12341234"
});
let response = request.post("/api/auth/register").json(&payload).await;
assert_eq!(
response.status_code(),
200,
"Register request should succeed"
);
let saved_user = users::Model::find_by_email(&ctx.db, email).await;
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!(saved_user);
});
let deliveries = ctx.mailer.unwrap().deliveries();
assert_eq!(deliveries.count, 1, "Exactly one email should be sent");
// with_settings!({
// filters => cleanup_email()
// }, {
// assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
// });
})
.await;
}
#[rstest]
#[case("login_with_valid_password", "12341234")]
#[case("login_with_invalid_password", "invalid-password")]
#[tokio::test]
#[serial]
async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
let email = "test@loco.com";
let register_payload = serde_json::json!({
"name": "loco",
"email": email,
"password": "12341234"
});
//Creating a new user
let register_response = request
.post("/api/auth/register")
.json(&register_payload)
.await;
assert_eq!(
register_response.status_code(),
200,
"Register request should succeed"
);
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
let email_verification_token = user
.email_verification_token
.expect("Email verification token should be generated");
request
.get(&format!("/api/auth/verify/{email_verification_token}"))
.await;
//verify user request
let response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": email,
"password": password
}))
.await;
// Make sure email_verified_at is set
let user = users::Model::find_by_email(&ctx.db, email)
.await
.expect("Failed to find user by email");
assert!(
user.email_verified_at.is_some(),
"Expected the email to be verified, but it was not. User: {:?}",
user
);
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!(test_name, (response.status_code(), response.text()));
});
})
.await;
}
#[tokio::test]
#[serial]
async fn login_with_un_existing_email() {
configure_insta!();
request::<App, _, _>(|request, _ctx| async move {
let login_response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": "un_existing@loco.rs",
"password": "1234"
}))
.await;
assert_eq!(login_response.status_code(), 401, "Login request should return 401");
login_response.assert_json(&serde_json::json!({"error": "unauthorized", "description": "You do not have permission to access this resource"}));
})
.await;
}
#[tokio::test]
#[serial]
async fn can_login_without_verify() {
configure_insta!();
request::<App, _, _>(|request, _ctx| async move {
let email = "test@loco.com";
let password = "12341234";
let register_payload = serde_json::json!({
"name": "loco",
"email": email,
"password": password
});
//Creating a new user
let register_response = request
.post("/api/auth/register")
.json(&register_payload)
.await;
assert_eq!(
register_response.status_code(),
200,
"Register request should succeed"
);
//verify user request
let login_response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": email,
"password": password
}))
.await;
assert_eq!(
login_response.status_code(),
200,
"Login request should succeed"
);
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!(login_response.text());
});
})
.await;
}
#[tokio::test]
#[serial]
async fn invalid_verification_token() {
configure_insta!();
request::<App, _, _>(|request, _ctx| async move {
let response = request.get("/api/auth/verify/invalid-token").await;
assert_eq!(response.status_code(), 401, "Verify request should reject");
})
.await;
}
#[tokio::test]
#[serial]
async fn can_reset_password() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
let login_data = prepare_data::init_user_login(&request, &ctx).await;
let forgot_payload = serde_json::json!({
"email": login_data.user.email,
});
let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await;
assert_eq!(
forget_response.status_code(),
200,
"Forget request should succeed"
);
let user = users::Model::find_by_email(&ctx.db, &login_data.user.email)
.await
.expect("Failed to find user by email");
assert!(
user.reset_token.is_some(),
"Expected reset_token to be set, but it was None. User: {user:?}"
);
assert!(
user.reset_sent_at.is_some(),
"Expected reset_sent_at to be set, but it was None. User: {user:?}"
);
let new_password = "new-password";
let reset_payload = serde_json::json!({
"token": user.reset_token,
"password": new_password,
});
let reset_response = request.post("/api/auth/reset").json(&reset_payload).await;
assert_eq!(
reset_response.status_code(),
200,
"Reset password request should succeed"
);
let user = users::Model::find_by_email(&ctx.db, &user.email)
.await
.unwrap();
assert!(user.reset_token.is_none());
assert!(user.reset_sent_at.is_none());
assert_debug_snapshot!(reset_response.text());
let login_response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": user.email,
"password": new_password
}))
.await;
assert_eq!(
login_response.status_code(),
200,
"Login request should succeed"
);
let deliveries = ctx.mailer.unwrap().deliveries();
assert_eq!(deliveries.count, 2, "Exactly one email should be sent");
// with_settings!({
// filters => cleanup_email()
// }, {
// assert_debug_snapshot!(deliveries.messages);
// });
})
.await;
}
#[tokio::test]
#[serial]
async fn can_get_current_user() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
let user = prepare_data::init_user_login(&request, &ctx).await;
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
let response = request
.get("/api/auth/current")
.add_header(auth_key, auth_value)
.await;
assert_eq!(
response.status_code(),
200,
"Current request should succeed"
);
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!((response.status_code(), response.text()));
});
})
.await;
}
#[tokio::test]
#[serial]
async fn can_auth_with_magic_link() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
seed::<App>(&ctx).await.unwrap();
let payload = serde_json::json!({
"email": "user1@example.com",
});
let response = request.post("/api/auth/magic-link").json(&payload).await;
assert_eq!(
response.status_code(),
200,
"Magic link request should succeed"
);
let deliveries = ctx.mailer.unwrap().deliveries();
assert_eq!(deliveries.count, 1, "Exactly one email should be sent");
// let redact_token = format!("[a-zA-Z0-9]{{{}}}", users::MAGIC_LINK_LENGTH);
// with_settings!({
// filters => {
// let mut combined_filters = cleanup_email().clone();
// combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]);
// combined_filters
// }
// }, {
// assert_debug_snapshot!(deliveries.messages);
// });
let user = users::Model::find_by_email(&ctx.db, "user1@example.com")
.await
.expect("User should be found");
let magic_link_token = user
.magic_link_token
.expect("Magic link token should be generated");
let magic_link_response = request
.get(&format!("/api/auth/magic-link/{magic_link_token}"))
.await;
assert_eq!(
magic_link_response.status_code(),
200,
"Magic link authentication should succeed"
);
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!(magic_link_response.text());
});
})
.await;
}
#[tokio::test]
#[serial]
async fn can_reject_invalid_email() {
configure_insta!();
request::<App, _, _>(|request, _ctx| async move {
let invalid_email = "user1@temp-mail.com";
let payload = serde_json::json!({
"email": invalid_email,
});
let response = request.post("/api/auth/magic-link").json(&payload).await;
assert_eq!(
response.status_code(),
400,
"Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed."
);
})
.await;
}
#[tokio::test]
#[serial]
async fn can_reject_invalid_magic_link_token() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
seed::<App>(&ctx).await.unwrap();
let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await;
assert_eq!(
magic_link_response.status_code(),
401,
"Magic link authentication should be rejected"
);
})
.await;
}
#[tokio::test]
#[serial]
async fn can_resend_verification_email() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
let email = "test@loco.com";
let payload = serde_json::json!({
"name": "loco",
"email": email,
"password": "12341234"
});
let response = request.post("/api/auth/register").json(&payload).await;
assert_eq!(
response.status_code(),
200,
"Register request should succeed"
);
let resend_payload = serde_json::json!({ "email": email });
let resend_response = request
.post("/api/auth/resend-verification-mail")
.json(&resend_payload)
.await;
assert_eq!(
resend_response.status_code(),
200,
"Resend verification email should succeed"
);
let deliveries = ctx.mailer.unwrap().deliveries();
assert_eq!(
deliveries.count, 2,
"Two emails should have been sent: welcome and re-verification"
);
let user = users::Model::find_by_email(&ctx.db, email)
.await
.expect("User should exist");
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!("resend_verification_user", user);
});
})
.await;
}
#[tokio::test]
#[serial]
async fn cannot_resend_email_if_already_verified() {
configure_insta!();
request::<App, _, _>(|request, ctx| async move {
let email = "verified@loco.com";
let payload = serde_json::json!({
"name": "verified",
"email": email,
"password": "12341234"
});
request.post("/api/auth/register").json(&payload).await;
// Verify user
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
if let Some(token) = user.email_verification_token.clone() {
request.get(&format!("/api/auth/verify/{token}")).await;
}
// Try resending verification email
let resend_payload = serde_json::json!({ "email": email });
let resend_response = request
.post("/api/auth/resend-verification-mail")
.json(&resend_payload)
.await;
assert_eq!(
resend_response.status_code(),
200,
"Should return 200 even if already verified"
);
let deliveries = ctx.mailer.unwrap().deliveries();
assert_eq!(
deliveries.count, 1,
"Only the original welcome email should be sent"
);
})
.await;
}
+185
View File
@@ -0,0 +1,185 @@
use book_manager::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
use super::prepare_data;
#[tokio::test]
#[serial]
async fn can_list_books_with_pagination() {
request::<App, _, _>(|request, _ctx| async move {
// First test if auth endpoint works (to verify routing is working)
let auth_response = request.get("/api/auth/current").await;
println!("Auth endpoint status: {}", auth_response.status_code());
// Now test books endpoint
let response = request.get("/api/books").await;
let status = response.status_code();
let body = response.text();
println!("Books endpoint status: {}", status);
println!("Books response (first 300 chars): {}", &body[..body.len().min(300)]);
// The route should return 200 with JSON, not HTML
assert_eq!(
status,
200,
"List books request should succeed"
);
// Check if it's JSON (not HTML)
assert!(
body.trim_start().starts_with('{'),
"Response should be JSON, but got: {}",
&body[..body.len().min(100)]
);
// Try to parse as JSON
let json: serde_json::Value = serde_json::from_str(&body)
.expect("Response should be valid JSON");
// Verify response structure
assert!(json.get("books").is_some(), "Response should contain 'books' field");
assert!(json.get("total").is_some(), "Response should contain 'total' field");
assert!(json.get("page").is_some(), "Response should contain 'page' field");
assert!(json.get("per_page").is_some(), "Response should contain 'per_page' field");
assert!(json.get("total_pages").is_some(), "Response should contain 'total_pages' field");
// Verify default values
assert_eq!(json["page"], 1, "Default page should be 1");
assert_eq!(json["per_page"], 12, "Default per_page should be 12");
})
.await;
}
#[tokio::test]
#[serial]
async fn can_list_books_with_custom_pagination() {
request::<App, _, _>(|request, _ctx| async move {
// Test custom pagination parameters
let response = request.get("/api/books?page=2&per_page=5").await;
assert_eq!(
response.status_code(),
200,
"List books with custom pagination should succeed"
);
let json: serde_json::Value = response.json();
assert_eq!(json["page"], 2, "Page should be 2");
assert_eq!(json["per_page"], 5, "Per page should be 5");
})
.await;
}
#[tokio::test]
#[serial]
async fn can_get_single_book() {
request::<App, _, _>(|request, ctx| async move {
// First create a test book
let user = prepare_data::init_user_login(&request, &ctx).await;
let book_payload = serde_json::json!({
"title": "Test Book",
"author": "Test Author",
"description": "A test book description",
"content": "This is the content of the test book."
});
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
let create_response = request
.post("/api/books")
.add_header(auth_key, auth_value.clone())
.json(&book_payload)
.await;
assert_eq!(
create_response.status_code(),
200,
"Create book request should succeed"
);
let created_book: serde_json::Value = create_response.json();
let book_id = created_book["id"].as_i64().unwrap();
// Now get the book by ID
let get_response = request.get(&format!("/api/books/{}", book_id)).await;
assert_eq!(
get_response.status_code(),
200,
"Get single book request should succeed"
);
let retrieved_book: serde_json::Value = get_response.json();
assert_eq!(retrieved_book["title"], "Test Book");
assert_eq!(retrieved_book["author"], "Test Author");
assert_eq!(retrieved_book["description"], "A test book description");
})
.await;
}
#[tokio::test]
#[serial]
async fn can_create_book_with_auth() {
request::<App, _, _>(|request, ctx| async move {
let user = prepare_data::init_user_login(&request, &ctx).await;
let book_payload = serde_json::json!({
"title": "Rust Programming",
"author": "Steve Klabnik",
"description": "Learn Rust programming",
"content": "Rust is a systems programming language..."
});
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
let response = request
.post("/api/books")
.add_header(auth_key, auth_value)
.json(&book_payload)
.await;
assert_eq!(
response.status_code(),
200,
"Create book request should succeed with authentication"
);
let json: serde_json::Value = response.json();
assert_eq!(json["title"], "Rust Programming");
assert_eq!(json["author"], "Steve Klabnik");
assert!(json.get("id").is_some(), "Created book should have an ID");
})
.await;
}
#[tokio::test]
#[serial]
async fn cannot_create_book_without_auth() {
request::<App, _, _>(|request, _ctx| async move {
let book_payload = serde_json::json!({
"title": "Unauthorized Book",
"author": "No Auth",
"content": "This should fail"
});
let response = request
.post("/api/books")
.json(&book_payload)
.await;
// Should return 401 Unauthorized or similar error
assert_ne!(
response.status_code(),
200,
"Create book should fail without authentication"
);
})
.await;
}
+21
View File
@@ -0,0 +1,21 @@
use book_manager::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn test_books_route_exists() {
request::<App, _, _>(|request, _ctx| async move {
// Try to access books test endpoint
let response = request.get("/api/books/test").await;
println!("Status: {}", response.status_code());
println!("Body: {}", response.text());
// Check if we get JSON response
assert_eq!(response.status_code(), 200);
let body = response.text();
assert!(body.contains("Books controller is working"), "Got: {}", body);
})
.await;
}
+4
View File
@@ -0,0 +1,4 @@
mod auth;
mod books;
mod books_debug;
mod prepare_data;
+57
View File
@@ -0,0 +1,57 @@
use axum::http::{HeaderName, HeaderValue};
use book_manager::{models::users, views::auth::LoginResponse};
use loco_rs::{app::AppContext, TestServer};
const USER_EMAIL: &str = "test@loco.com";
const USER_PASSWORD: &str = "1234";
pub struct LoggedInUser {
pub user: users::Model,
pub token: String,
}
pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser {
let register_payload = serde_json::json!({
"name": "loco",
"email": USER_EMAIL,
"password": USER_PASSWORD
});
//Creating a new user
request
.post("/api/auth/register")
.json(&register_payload)
.await;
let user = users::Model::find_by_email(&ctx.db, USER_EMAIL)
.await
.unwrap();
let verify_payload = serde_json::json!({
"token": user.email_verification_token,
});
request.post("/api/auth/verify").json(&verify_payload).await;
let response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": USER_EMAIL,
"password": USER_PASSWORD
}))
.await;
let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap();
LoggedInUser {
user: users::Model::find_by_email(&ctx.db, USER_EMAIL)
.await
.unwrap(),
token: login_response.token,
}
}
pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) {
let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap();
(HeaderName::from_static("authorization"), auth_header_value)
}
@@ -0,0 +1,5 @@
---
source: tests/requests/auth.rs
expression: magic_link_response.text()
---
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}"
@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: "(response.status_code(), response.text())"
---
(
200,
"{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}",
)
@@ -0,0 +1,5 @@
---
source: tests/requests/auth.rs
expression: login_response.text()
---
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}"
@@ -0,0 +1,27 @@
---
source: tests/requests/auth.rs
expression: saved_user
---
Ok(
Model {
created_at: DATE,
updated_at: DATE,
id: ID
pid: PID,
email: "test@loco.com",
password: "PASSWORD",
api_key: "lo-PID",
name: "loco",
reset_token: None,
reset_sent_at: None,
email_verification_token: Some(
"PID",
),
email_verification_sent_at: Some(
DATE,
),
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
@@ -0,0 +1,5 @@
---
source: tests/requests/auth.rs
expression: "(reset_response.status_code(), reset_response.text())"
---
"null"
@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: "(response.status_code(), response.text())"
---
(
401,
"{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}",
)
@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: "(response.status_code(), response.text())"
---
(
200,
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}",
)
@@ -0,0 +1,26 @@
---
source: tests/requests/auth.rs
assertion_line: 414
expression: user
---
Model {
created_at: DATE,
updated_at: DATE,
id: ID
pid: PID,
email: "test@loco.com",
password: "PASSWORD",
api_key: "lo-PID",
name: "loco",
reset_token: None,
reset_sent_at: None,
email_verification_token: Some(
"PID",
),
email_verification_sent_at: Some(
DATE,
),
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
}
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@