commit b230ff0b5a849714463335c67d60f7dde723f174 Author: Priec Date: Fri May 15 17:35:09 2026 +0200 ht booking app in loco diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..be2684b --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1778443072, + "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778815121, + "narHash": "sha256-xlhD+1NVJbhrUUM2usRHW6iKWTXP2uw2Fo6sWJmLg8g=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "017351829a9356423afd2cca0dde9b63346c8ab3", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ced23c0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,58 @@ +{ + description = "Development Nix flake for the tennis court booking app (Loco.rs)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, rust-overlay, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems f; + in + { + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + rust = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + }; + in + { + default = pkgs.mkShell { + buildInputs = [ + rust + pkgs.pkg-config + pkgs.openssl + pkgs.sqlite + pkgs.cmake + pkgs.llvmPackages.clang + pkgs.llvmPackages.libclang.lib + ]; + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + # Use clang for any BoringSSL / bindgen-driven compilation + shellHook = '' + export CC=clang + export CXX=clang++ + echo " Tennis court booking dev shell ready" + echo " rust: $(rustc --version)" + echo " tip: cargo install loco-cli sea-orm-cli" + ''; + }; + } + ); + }; +} diff --git a/ht_booking/.cargo/config.toml b/ht_booking/.cargo/config.toml new file mode 100644 index 0000000..5caf6b1 --- /dev/null +++ b/ht_booking/.cargo/config.toml @@ -0,0 +1,10 @@ +[alias] +loco = "run --" +loco-tool = "run --" + +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" diff --git a/ht_booking/.github/workflows/ci.yaml b/ht_booking/.github/workflows/ci.yaml new file mode 100644 index 0000000..75ba8a5 --- /dev/null +++ b/ht_booking/.github/workflows/ci.yaml @@ -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 + diff --git a/ht_booking/.gitignore b/ht_booking/.gitignore new file mode 100644 index 0000000..510f9fb --- /dev/null +++ b/ht_booking/.gitignore @@ -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-* \ No newline at end of file diff --git a/ht_booking/.rustfmt.toml b/ht_booking/.rustfmt.toml new file mode 100644 index 0000000..d862e08 --- /dev/null +++ b/ht_booking/.rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +use_small_heuristics = "Default" diff --git a/ht_booking/Cargo.toml b/ht_booking/Cargo.toml new file mode 100644 index 0000000..07756f3 --- /dev/null +++ b/ht_booking/Cargo.toml @@ -0,0 +1,53 @@ +[workspace] + +[package] +name = "ht_booking" +version = "0.1.0" +edition = "2021" +publish = false +default-run = "ht_booking-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" } +# view engine i18n +fluent-templates = { version = "0.13", features = ["tera"] } +unic-langid = { version = "0.9" } +# /view engine +axum-extra = { version = "0.10", features = ["form"] } + +[[bin]] +name = "ht_booking-cli" +path = "src/bin/main.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"] } diff --git a/ht_booking/README.md b/ht_booking/README.md new file mode 100644 index 0000000..43b9bdd --- /dev/null +++ b/ht_booking/README.md @@ -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/). diff --git a/ht_booking/assets/i18n/de-DE/main.ftl b/ht_booking/assets/i18n/de-DE/main.ftl new file mode 100644 index 0000000..ced609f --- /dev/null +++ b/ht_booking/assets/i18n/de-DE/main.ftl @@ -0,0 +1,4 @@ +hello-world = Hallo Welt! +greeting = Hallochen { $name }! + .placeholder = Hallo Freund! +about = Uber diff --git a/ht_booking/assets/i18n/en-US/main.ftl b/ht_booking/assets/i18n/en-US/main.ftl new file mode 100644 index 0000000..9d4d5e7 --- /dev/null +++ b/ht_booking/assets/i18n/en-US/main.ftl @@ -0,0 +1,10 @@ +hello-world = Hello World! +greeting = Hello { $name }! + .placeholder = Hello Friend! +about = About +simple = simple text +reference = simple text with a reference: { -something } +parameter = text with a { $param } +parameter2 = text one { $param } second { $multi-word-param } +email = text with an EMAIL("example@example.org") +fallback = this should fall back diff --git a/ht_booking/assets/i18n/shared.ftl b/ht_booking/assets/i18n/shared.ftl new file mode 100644 index 0000000..f169eca --- /dev/null +++ b/ht_booking/assets/i18n/shared.ftl @@ -0,0 +1 @@ +-something = foo diff --git a/ht_booking/assets/static/404.html b/ht_booking/assets/static/404.html new file mode 100644 index 0000000..66e78fb --- /dev/null +++ b/ht_booking/assets/static/404.html @@ -0,0 +1,3 @@ + +not found :-( + diff --git a/ht_booking/assets/static/image.png b/ht_booking/assets/static/image.png new file mode 100644 index 0000000..fa5a095 Binary files /dev/null and b/ht_booking/assets/static/image.png differ diff --git a/ht_booking/assets/views/home/hello.html b/ht_booking/assets/views/home/hello.html new file mode 100644 index 0000000..6b97c39 --- /dev/null +++ b/ht_booking/assets/views/home/hello.html @@ -0,0 +1,12 @@ + + +
+ find this tera template at assets/views/home/hello.html: +
+
+ {{ t(key="hello-world", lang="en-US") }}, +
+ {{ t(key="hello-world", lang="de-DE") }} + + + \ No newline at end of file diff --git a/ht_booking/config/development.yaml b/ht_booking/config/development.yaml new file mode 100644 index 0000000..e3519c3 --- /dev/null +++ b/ht_booking/config/development.yaml @@ -0,0 +1,99 @@ +# 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: + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + fallback: "assets/static/404.html" + +# 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://ht_booking_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: DVSmZfzwS2ipoqUzfRGA + # Token expiration time in seconds + expiration: 604800 # 7 days diff --git a/ht_booking/config/test.yaml b/ht_booking/config/test.yaml new file mode 100644 index 0000000..8e40525 --- /dev/null +++ b/ht_booking/config/test.yaml @@ -0,0 +1,96 @@ +# 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: + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + fallback: "assets/static/404.html" + +# 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://ht_booking_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: XMVYBAdR80Kop3WiaydJ + # Token expiration time in seconds + expiration: 604800 # 7 days diff --git a/ht_booking/examples/playground.rs b/ht_booking/examples/playground.rs new file mode 100644 index 0000000..ec6fe80 --- /dev/null +++ b/ht_booking/examples/playground.rs @@ -0,0 +1,21 @@ +use ht_booking::app::App; +#[allow(unused_imports)] +use loco_rs::{cli::playground, prelude::*}; + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + let _ctx = playground::().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(()) +} diff --git a/ht_booking/migration/Cargo.toml b/ht_booking/migration/Cargo.toml new file mode 100644 index 0000000..d191b87 --- /dev/null +++ b/ht_booking/migration/Cargo.toml @@ -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 +] diff --git a/ht_booking/migration/src/lib.rs b/ht_booking/migration/src/lib.rs new file mode 100644 index 0000000..86c1ae7 --- /dev/null +++ b/ht_booking/migration/src/lib.rs @@ -0,0 +1,16 @@ +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m20220101_000001_users; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_users::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/ht_booking/migration/src/m20220101_000001_users.rs b/ht_booking/migration/src/m20220101_000001_users.rs new file mode 100644 index 0000000..27d4b94 --- /dev/null +++ b/ht_booking/migration/src/m20220101_000001_users.rs @@ -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(()) + } +} diff --git a/ht_booking/src/app.rs b/ht_booking/src/app.rs new file mode 100644 index 0000000..29d5f00 --- /dev/null +++ b/ht_booking/src/app.rs @@ -0,0 +1,74 @@ +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, initializers, 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 { + create_app::(mode, environment, config).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![Box::new( + initializers::view_engine::ViewEngineInitializer, + )]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::auth::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::(&ctx.db, &base.join("users.yaml").display().to_string()) + .await?; + Ok(()) + } +} diff --git a/ht_booking/src/bin/main.rs b/ht_booking/src/bin/main.rs new file mode 100644 index 0000000..6bb43e3 --- /dev/null +++ b/ht_booking/src/bin/main.rs @@ -0,0 +1,8 @@ +use ht_booking::app::App; +use loco_rs::cli; +use migration::Migrator; + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + cli::main::().await +} diff --git a/ht_booking/src/controllers/auth.rs b/ht_booking/src/controllers/auth.rs new file mode 100644 index 0000000..e66d448 --- /dev/null +++ b/ht_booking/src/controllers/auth.rs @@ -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 = 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, + Json(params): Json, +) -> Result { + let res = users::Model::create_with_password(&ctx.db, ¶ms).await; + + let user = match res { + Ok(user) => user, + Err(err) => { + tracing::info!( + message = err.to_string(), + user_email = ¶ms.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, Path(token): Path) -> Result { + 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, + Json(params): Json, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.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, Json(params): Json) -> Result { + let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.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, ¶ms.password) + .await?; + + format::json(()) +} + +/// Creates a user login and returns a token +#[debug_handler] +async fn login(State(ctx): State, Json(params): Json) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + tracing::debug!( + email = params.email, + "login attempt with non-existent email" + ); + return unauthorized("Invalid credentials!"); + }; + + let valid = user.verify_password(¶ms.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) -> Result { + 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, + Json(params): Json, +) -> Result { + let email_regex = get_allow_email_domain_re(); + if !email_regex.is_match(¶ms.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, ¶ms.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, + State(ctx): State, +) -> Result { + 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, + Json(params): Json, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.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)) +} diff --git a/ht_booking/src/controllers/mod.rs b/ht_booking/src/controllers/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/ht_booking/src/controllers/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/ht_booking/src/data/mod.rs b/ht_booking/src/data/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ht_booking/src/data/mod.rs @@ -0,0 +1 @@ + diff --git a/ht_booking/src/fixtures/users.yaml b/ht_booking/src/fixtures/users.yaml new file mode 100644 index 0000000..8f5b5ed --- /dev/null +++ b/ht_booking/src/fixtures/users.yaml @@ -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" diff --git a/ht_booking/src/initializers/mod.rs b/ht_booking/src/initializers/mod.rs new file mode 100644 index 0000000..cbe3470 --- /dev/null +++ b/ht_booking/src/initializers/mod.rs @@ -0,0 +1 @@ +pub mod view_engine; diff --git a/ht_booking/src/initializers/view_engine.rs b/ht_booking/src/initializers/view_engine.rs new file mode 100644 index 0000000..b6a6855 --- /dev/null +++ b/ht_booking/src/initializers/view_engine.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use axum::{Extension, Router as AxumRouter}; +use fluent_templates::{ArcLoader, FluentLoader}; +use loco_rs::{ + app::{AppContext, Initializer}, + controller::views::{engines, ViewEngine}, + Error, Result, +}; +use tracing::info; + +const I18N_DIR: &str = "assets/i18n"; +const I18N_SHARED: &str = "assets/i18n/shared.ftl"; +#[allow(clippy::module_name_repetitions)] +pub struct ViewEngineInitializer; + +#[async_trait] +impl Initializer for ViewEngineInitializer { + fn name(&self) -> String { + "view-engine".to_string() + } + + async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result { + let tera_engine = if std::path::Path::new(I18N_DIR).exists() { + let arc = std::sync::Arc::new( + ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) + .shared_resources(Some(&[I18N_SHARED.into()])) + .customize(|bundle| bundle.set_use_isolating(false)) + .build() + .map_err(|e| Error::string(&e.to_string()))?, + ); + info!("locales loaded"); + + engines::TeraView::build()?.post_process(move |tera| { + tera.register_function("t", FluentLoader::new(arc.clone())); + Ok(()) + })? + } else { + engines::TeraView::build()? + }; + + Ok(router.layer(Extension(ViewEngine::from(tera_engine)))) + } +} diff --git a/ht_booking/src/lib.rs b/ht_booking/src/lib.rs new file mode 100644 index 0000000..e8859fb --- /dev/null +++ b/ht_booking/src/lib.rs @@ -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; diff --git a/ht_booking/src/mailers/auth.rs b/ht_booking/src/mailers/auth.rs new file mode 100644 index 0000000..88b949a --- /dev/null +++ b/ht_booking/src/mailers/auth.rs @@ -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(()) + } +} diff --git a/ht_booking/src/mailers/auth/forgot/html.t b/ht_booking/src/mailers/auth/forgot/html.t new file mode 100644 index 0000000..221dd60 --- /dev/null +++ b/ht_booking/src/mailers/auth/forgot/html.t @@ -0,0 +1,11 @@ +; + + + Hey {{name}}, + Forgot your password? No worries! You can reset it by clicking the link below: + Reset Your Password + If you didn't request a password reset, please ignore this email. + Best regards,
The Loco Team
+ + + diff --git a/ht_booking/src/mailers/auth/forgot/subject.t b/ht_booking/src/mailers/auth/forgot/subject.t new file mode 100644 index 0000000..4938df1 --- /dev/null +++ b/ht_booking/src/mailers/auth/forgot/subject.t @@ -0,0 +1 @@ +Your reset password link diff --git a/ht_booking/src/mailers/auth/forgot/text.t b/ht_booking/src/mailers/auth/forgot/text.t new file mode 100644 index 0000000..58c30fd --- /dev/null +++ b/ht_booking/src/mailers/auth/forgot/text.t @@ -0,0 +1,3 @@ +Reset your password with this link: + +http://localhost/reset#{{resetToken}} diff --git a/ht_booking/src/mailers/auth/magic_link/html.t b/ht_booking/src/mailers/auth/magic_link/html.t new file mode 100644 index 0000000..56eb252 --- /dev/null +++ b/ht_booking/src/mailers/auth/magic_link/html.t @@ -0,0 +1,8 @@ +; + +

Magic link example:

+ +Verify Your Account + + + diff --git a/ht_booking/src/mailers/auth/magic_link/subject.t b/ht_booking/src/mailers/auth/magic_link/subject.t new file mode 100644 index 0000000..93eaba7 --- /dev/null +++ b/ht_booking/src/mailers/auth/magic_link/subject.t @@ -0,0 +1 @@ +Magic link example diff --git a/ht_booking/src/mailers/auth/magic_link/text.t b/ht_booking/src/mailers/auth/magic_link/text.t new file mode 100644 index 0000000..b33d331 --- /dev/null +++ b/ht_booking/src/mailers/auth/magic_link/text.t @@ -0,0 +1,2 @@ +Magic link with this link: +{{host}}/api/auth/magic-link/{{token}} \ No newline at end of file diff --git a/ht_booking/src/mailers/auth/welcome/html.t b/ht_booking/src/mailers/auth/welcome/html.t new file mode 100644 index 0000000..dcca19e --- /dev/null +++ b/ht_booking/src/mailers/auth/welcome/html.t @@ -0,0 +1,13 @@ +; + + + 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: + + Verify Your Account + +

Best regards,
The Loco Team

+ + + diff --git a/ht_booking/src/mailers/auth/welcome/subject.t b/ht_booking/src/mailers/auth/welcome/subject.t new file mode 100644 index 0000000..82cc6fb --- /dev/null +++ b/ht_booking/src/mailers/auth/welcome/subject.t @@ -0,0 +1 @@ +Welcome {{name}} diff --git a/ht_booking/src/mailers/auth/welcome/text.t b/ht_booking/src/mailers/auth/welcome/text.t new file mode 100644 index 0000000..2a73f2c --- /dev/null +++ b/ht_booking/src/mailers/auth/welcome/text.t @@ -0,0 +1,4 @@ +Welcome {{name}}, you can now log in. + Verify your account with the link below: + + {{domain}}/api/auth/verify/{{verifyToken}} diff --git a/ht_booking/src/mailers/mod.rs b/ht_booking/src/mailers/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/ht_booking/src/mailers/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/ht_booking/src/models/_entities/mod.rs b/ht_booking/src/models/_entities/mod.rs new file mode 100644 index 0000000..7efa3a0 --- /dev/null +++ b/ht_booking/src/models/_entities/mod.rs @@ -0,0 +1,4 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub mod prelude; +pub mod users; diff --git a/ht_booking/src/models/_entities/prelude.rs b/ht_booking/src/models/_entities/prelude.rs new file mode 100644 index 0000000..1055169 --- /dev/null +++ b/ht_booking/src/models/_entities/prelude.rs @@ -0,0 +1,2 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +pub use super::users::Entity as Users; diff --git a/ht_booking/src/models/_entities/users.rs b/ht_booking/src/models/_entities/users.rs new file mode 100644 index 0000000..765e992 --- /dev/null +++ b/ht_booking/src/models/_entities/users.rs @@ -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, + pub reset_sent_at: Option, + pub email_verification_token: Option, + pub email_verification_sent_at: Option, + pub email_verified_at: Option, + pub magic_link_token: Option, + pub magic_link_expiration: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/ht_booking/src/models/mod.rs b/ht_booking/src/models/mod.rs new file mode 100644 index 0000000..48da463 --- /dev/null +++ b/ht_booking/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod _entities; +pub mod users; diff --git a/ht_booking/src/models/users.rs b/ht_booking/src/models/users.rs new file mode 100644 index 0000000..2292ded --- /dev/null +++ b/ht_booking/src/models/users.rs @@ -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 { + 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(self, _db: &C, insert: bool) -> Result + 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 { + 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::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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let txn = db.begin().await?; + + if users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Email, ¶ms.email) + .build(), + ) + .one(&txn) + .await? + .is_some() + { + return Err(ModelError::EntityAlreadyExists {}); + } + + let password_hash = + hash::hash_password(¶ms.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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + self.magic_link_token = ActiveValue::set(None); + self.magic_link_expiration = ActiveValue::set(None); + self.update(db).await.map_err(ModelError::from) + } +} diff --git a/ht_booking/src/tasks/mod.rs b/ht_booking/src/tasks/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ht_booking/src/tasks/mod.rs @@ -0,0 +1 @@ + diff --git a/ht_booking/src/views/auth.rs b/ht_booking/src/views/auth.rs new file mode 100644 index 0000000..3d2d74f --- /dev/null +++ b/ht_booking/src/views/auth.rs @@ -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(), + } + } +} diff --git a/ht_booking/src/views/mod.rs b/ht_booking/src/views/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/ht_booking/src/views/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/ht_booking/src/workers/downloader.rs b/ht_booking/src/workers/downloader.rs new file mode 100644 index 0000000..1abafa4 --- /dev/null +++ b/ht_booking/src/workers/downloader.rs @@ -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 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(()) + } +} diff --git a/ht_booking/src/workers/mod.rs b/ht_booking/src/workers/mod.rs new file mode 100644 index 0000000..acb5733 --- /dev/null +++ b/ht_booking/src/workers/mod.rs @@ -0,0 +1 @@ +pub mod downloader; diff --git a/ht_booking/tests/mod.rs b/ht_booking/tests/mod.rs new file mode 100644 index 0000000..b42f234 --- /dev/null +++ b/ht_booking/tests/mod.rs @@ -0,0 +1,4 @@ +mod models; +mod requests; +mod tasks; +mod workers; diff --git a/ht_booking/tests/models/mod.rs b/ht_booking/tests/models/mod.rs new file mode 100644 index 0000000..5975988 --- /dev/null +++ b/ht_booking/tests/models/mod.rs @@ -0,0 +1 @@ +mod users; diff --git a/ht_booking/tests/models/snapshots/can_create_with_password@users.snap b/ht_booking/tests/models/snapshots/can_create_with_password@users.snap new file mode 100644 index 0000000..9811362 --- /dev/null +++ b/ht_booking/tests/models/snapshots/can_create_with_password@users.snap @@ -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, + }, +) diff --git a/ht_booking/tests/models/snapshots/can_find_by_email@users-2.snap b/ht_booking/tests/models/snapshots/can_find_by_email@users-2.snap new file mode 100644 index 0000000..25c700a --- /dev/null +++ b/ht_booking/tests/models/snapshots/can_find_by_email@users-2.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: non_existing_user_results +--- +Err( + EntityNotFound, +) diff --git a/ht_booking/tests/models/snapshots/can_find_by_email@users.snap b/ht_booking/tests/models/snapshots/can_find_by_email@users.snap new file mode 100644 index 0000000..518753a --- /dev/null +++ b/ht_booking/tests/models/snapshots/can_find_by_email@users.snap @@ -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, + }, +) diff --git a/ht_booking/tests/models/snapshots/can_find_by_pid@users-2.snap b/ht_booking/tests/models/snapshots/can_find_by_pid@users-2.snap new file mode 100644 index 0000000..25c700a --- /dev/null +++ b/ht_booking/tests/models/snapshots/can_find_by_pid@users-2.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: non_existing_user_results +--- +Err( + EntityNotFound, +) diff --git a/ht_booking/tests/models/snapshots/can_find_by_pid@users.snap b/ht_booking/tests/models/snapshots/can_find_by_pid@users.snap new file mode 100644 index 0000000..518753a --- /dev/null +++ b/ht_booking/tests/models/snapshots/can_find_by_pid@users.snap @@ -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, + }, +) diff --git a/ht_booking/tests/models/snapshots/can_validate_model@users.snap b/ht_booking/tests/models/snapshots/can_validate_model@users.snap new file mode 100644 index 0000000..add2079 --- /dev/null +++ b/ht_booking/tests/models/snapshots/can_validate_model@users.snap @@ -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.\"}]}", + ), +) diff --git a/ht_booking/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap b/ht_booking/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap new file mode 100644 index 0000000..ff28ea1 --- /dev/null +++ b/ht_booking/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: new_user +--- +Err( + EntityAlreadyExists, +) diff --git a/ht_booking/tests/models/users.rs b/ht_booking/tests/models/users.rs new file mode 100644 index 0000000..6d2cf0e --- /dev/null +++ b/ht_booking/tests/models/users.rs @@ -0,0 +1,360 @@ +use chrono::{offset::Local, Duration}; +use ht_booking::{ + app::App, + models::users::{self, Model, RegisterParams}, +}; +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::() + .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::() + .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, ¶ms).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::() + .await + .expect("Failed to boot test application"); + seed::(&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::() + .await + .expect("Failed to boot test application"); + seed::(&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::() + .await + .expect("Failed to boot test application"); + seed::(&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::() + .await + .expect("Failed to boot test application"); + seed::(&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::() + .await + .expect("Failed to boot test application"); + seed::(&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::() + .await + .expect("Failed to boot test application"); + seed::(&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::() + .await + .expect("Failed to boot test application"); + seed::(&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::().await.unwrap(); + seed::(&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" + ); +} diff --git a/ht_booking/tests/requests/auth.rs b/ht_booking/tests/requests/auth.rs new file mode 100644 index 0000000..80a3852 --- /dev/null +++ b/ht_booking/tests/requests/auth.rs @@ -0,0 +1,502 @@ +use ht_booking::{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::(|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::(|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(®ister_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::(|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::(|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(®ister_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::(|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::(|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::(|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::(|request, ctx| async move { + seed::(&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::(|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::(|request, ctx| async move { + seed::(&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::(|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::(|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; +} diff --git a/ht_booking/tests/requests/mod.rs b/ht_booking/tests/requests/mod.rs new file mode 100644 index 0000000..887b7ce --- /dev/null +++ b/ht_booking/tests/requests/mod.rs @@ -0,0 +1,2 @@ +mod auth; +mod prepare_data; diff --git a/ht_booking/tests/requests/prepare_data.rs b/ht_booking/tests/requests/prepare_data.rs new file mode 100644 index 0000000..061400d --- /dev/null +++ b/ht_booking/tests/requests/prepare_data.rs @@ -0,0 +1,57 @@ +use axum::http::{HeaderName, HeaderValue}; +use ht_booking::{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(®ister_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) +} diff --git a/ht_booking/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap b/ht_booking/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap new file mode 100644 index 0000000..1999857 --- /dev/null +++ b/ht_booking/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: magic_link_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}" diff --git a/ht_booking/tests/requests/snapshots/can_get_current_user@auth_request.snap b/ht_booking/tests/requests/snapshots/can_get_current_user@auth_request.snap new file mode 100644 index 0000000..74f7e71 --- /dev/null +++ b/ht_booking/tests/requests/snapshots/can_get_current_user@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}", +) diff --git a/ht_booking/tests/requests/snapshots/can_login_without_verify@auth_request.snap b/ht_booking/tests/requests/snapshots/can_login_without_verify@auth_request.snap new file mode 100644 index 0000000..1fac2fd --- /dev/null +++ b/ht_booking/tests/requests/snapshots/can_login_without_verify@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: login_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}" diff --git a/ht_booking/tests/requests/snapshots/can_register@auth_request.snap b/ht_booking/tests/requests/snapshots/can_register@auth_request.snap new file mode 100644 index 0000000..687580c --- /dev/null +++ b/ht_booking/tests/requests/snapshots/can_register@auth_request.snap @@ -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, + }, +) diff --git a/ht_booking/tests/requests/snapshots/can_reset_password@auth_request.snap b/ht_booking/tests/requests/snapshots/can_reset_password@auth_request.snap new file mode 100644 index 0000000..d426079 --- /dev/null +++ b/ht_booking/tests/requests/snapshots/can_reset_password@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: "(reset_response.status_code(), reset_response.text())" +--- +"null" \ No newline at end of file diff --git a/ht_booking/tests/requests/snapshots/login_with_invalid_password@auth_request.snap b/ht_booking/tests/requests/snapshots/login_with_invalid_password@auth_request.snap new file mode 100644 index 0000000..eb6e89f --- /dev/null +++ b/ht_booking/tests/requests/snapshots/login_with_invalid_password@auth_request.snap @@ -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\"}", +) diff --git a/ht_booking/tests/requests/snapshots/login_with_valid_password@auth_request.snap b/ht_booking/tests/requests/snapshots/login_with_valid_password@auth_request.snap new file mode 100644 index 0000000..f06fbaa --- /dev/null +++ b/ht_booking/tests/requests/snapshots/login_with_valid_password@auth_request.snap @@ -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}", +) diff --git a/ht_booking/tests/requests/snapshots/resend_verification_user@auth_request.snap b/ht_booking/tests/requests/snapshots/resend_verification_user@auth_request.snap new file mode 100644 index 0000000..055b6ca --- /dev/null +++ b/ht_booking/tests/requests/snapshots/resend_verification_user@auth_request.snap @@ -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, +} diff --git a/ht_booking/tests/tasks/mod.rs b/ht_booking/tests/tasks/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ht_booking/tests/tasks/mod.rs @@ -0,0 +1 @@ + diff --git a/ht_booking/tests/workers/mod.rs b/ht_booking/tests/workers/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ht_booking/tests/workers/mod.rs @@ -0,0 +1 @@ +