From 8c35db7373c2b47d0f6a9b79830741423b18dff6 Mon Sep 17 00:00:00 2001 From: Mustapha Date: Thu, 4 Sep 2025 15:43:17 +0100 Subject: [PATCH] finish: chapter 10 --- .env.example | 1 + .env.test | 1 + .github/workflows/cd.yml | 1 + Cargo.lock | 115 ++++++++++++++++ Cargo.toml | 10 ++ src/config.rs | 1 + src/errors.rs | 24 +++- src/handlers/admin/mod.rs | 37 ++++++ src/handlers/home/home.html | 10 ++ src/handlers/home/mod.rs | 7 + src/handlers/login/get.rs | 49 +++++++ src/handlers/login/login.html | 31 +++++ src/handlers/login/mod.rs | 2 + src/handlers/login/post.rs | 48 +++++++ src/handlers/mod.rs | 5 + src/handlers/newsletter.rs | 62 ++++++++- src/lib.rs | 1 + src/model.rs | 37 +++++- src/session_state.rs | 53 ++++++++ src/startup.rs | 15 ++- .../migrations/definitions/_initial.json | 2 +- surrealdb/schemas/sessions.surql | 3 + surrealdb/schemas/users.surql | 11 ++ tests/api/admin_dashboard.rs | 16 +++ tests/api/helpers.rs | 37 +++++- tests/api/login.rs | 67 ++++++++++ tests/api/main.rs | 2 + tests/api/newsletter.rs | 123 +++++++++++++++++- 28 files changed, 750 insertions(+), 21 deletions(-) create mode 100644 src/handlers/admin/mod.rs create mode 100644 src/handlers/home/home.html create mode 100644 src/handlers/home/mod.rs create mode 100644 src/handlers/login/get.rs create mode 100644 src/handlers/login/login.html create mode 100644 src/handlers/login/mod.rs create mode 100644 src/handlers/login/post.rs create mode 100644 src/session_state.rs create mode 100644 surrealdb/schemas/sessions.surql create mode 100644 surrealdb/schemas/users.surql create mode 100644 tests/api/admin_dashboard.rs create mode 100644 tests/api/login.rs diff --git a/.env.example b/.env.example index cb0a3a5..4a79422 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ RUST_LOG=info # https://docs.rs/env_logger/latest/env_logger/#enabling-logging SUBSCRIPTIONS__PORT=1337 SUBSCRIPTIONS__HOST=0.0.0.0 SUBSCRIPTIONS__BASE_URL=http://localhost:1337 +SUBSCRIPTIONS__HMAC_SECRET=secret # Subscriptions Database SUBSCRIPTIONS__DATABASE__BASE_URL=ws://database:4000 diff --git a/.env.test b/.env.test index aa8f03a..751e88e 100644 --- a/.env.test +++ b/.env.test @@ -4,6 +4,7 @@ RUST_LOG=debug # https://docs.rs/env_logger/latest/env_logger/#enabling-logging SUBSCRIPTIONS__PORT=1337 # not used by tests SUBSCRIPTIONS__HOST=0.0.0.0 # not used by tests SUBSCRIPTIONS__BASE_URL=http://localhost:1337 # not used by tests +SUBSCRIPTIONS__HMAC_SECRET=secret # Subscriptions Database SUBSCRIPTIONS__DATABASE__BASE_URL=mem:// diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 22e40ab..c339270 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -84,6 +84,7 @@ jobs: SUBSCRIPTIONS__EMAIL_CLIENT__BASE_URL=${{ vars.SUBSCRIPTIONS__EMAIL_CLIENT__BASE_URL }} SUBSCRIPTIONS__EMAIL_CLIENT__AUTH_TOKEN=${{ secrets.SUBSCRIPTIONS__EMAIL_CLIENT__AUTH_TOKEN }} SUBSCRIPTIONS__EMAIL_CLIENT__TIMEOUT=${{ vars.SUBSCRIPTIONS__EMAIL_CLIENT__TIMEOUT }} + SUBSCRIPTIONS__HMAC_SECRET=${{ vars.SUBSCRIPTIONS__HMAC_SECRET }} - name: Deployment URL run: 'echo "${{ steps.deploy.outputs.url }}"' diff --git a/Cargo.lock b/Cargo.lock index 376da70..29c3011 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,6 +463,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-messages" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67ce6e7bc1e1e71f2a4e86d418045a29c63c4ebb631f3d9bb2f81c4958ea391" +dependencies = [ + "axum-core", + "http", + "parking_lot", + "serde", + "serde_json", + "tower", + "tower-sessions-core", + "tracing", +] + [[package]] name = "axum-test" version = "18.0.2" @@ -1039,6 +1055,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] @@ -1900,6 +1917,12 @@ dependencies = [ "match_token", ] +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + [[package]] name = "http" version = "1.3.1" @@ -2464,6 +2487,7 @@ checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", + "serde", ] [[package]] @@ -4303,11 +4327,16 @@ name = "subscriptions" version = "0.1.0" dependencies = [ "axum", + "axum-messages", "axum-test", + "base64 0.22.1", "claims", "config", "dotenvy", "fake", + "hex", + "hmac", + "htmlescape", "include_dir", "linkify", "mime", @@ -4318,16 +4347,21 @@ dependencies = [ "serde", "serde-humantime", "serde_json", + "sha2", "surrealdb", "surrealdb-migrations", "thiserror 2.0.16", "tokio", "tower", + "tower-cookies", "tower-http", + "tower-sessions", + "tower-sessions-surrealdb-store", "tracing", "tracing-subscriber", "unicode-segmentation", "url", + "urlencoding", "validator", "wiremock", ] @@ -4891,6 +4925,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.6" @@ -4923,6 +4973,71 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 2.0.16", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-surrealdb-store" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506e6bb3e5d6c9b9d80dd3070a01032c6d1436c411002ed07fe4e2cf1ce71ccc" +dependencies = [ + "async-trait", + "rmp-serde", + "serde", + "surrealdb", + "tower-sessions-core", + "tracing", +] + [[package]] name = "tracing" version = "0.1.41" diff --git a/Cargo.toml b/Cargo.toml index b8d6e5d..482b559 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,16 @@ validator = "0.20.0" serde_json = "1.0.143" rand = { version = "0.9.2", features = ["std_rng"] } include_dir = "0.7.4" +base64 = "0.22.1" +urlencoding = "2.1.3" +htmlescape = "0.3.1" +hmac = { version = "0.12.1", features = ["std"] } +sha2 = "0.10.9" +hex = "0.4.3" +tower-cookies = "0.11.0" +axum-messages = "0.8.0" +tower-sessions = "0.14.0" +tower-sessions-surrealdb-store = "0.7.0" [dev-dependencies] mime = "0.3.17" diff --git a/src/config.rs b/src/config.rs index 6d44946..5bd97b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ pub struct Config { pub port: u16, pub host: String, pub base_url: Url, + pub hmac_secret: SecretString, pub database: DatabaseConfig, pub email_client: EmailClientConfig, } diff --git a/src/errors.rs b/src/errors.rs index cb579e1..95bc0ef 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,11 +1,15 @@ -use axum::{http::StatusCode, response::IntoResponse}; +use axum::{ + body::Body, + http::StatusCode, + response::{IntoResponse, Response}, +}; pub type Result = std::result::Result; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] - SurrealDb(Box), + SurrealDb(#[from] Box), #[error("{0:?}")] Migrations(String), #[error(transparent)] @@ -20,6 +24,10 @@ pub enum Error { ValidationError(#[from] validator::ValidationError), #[error(transparent)] Reqwest(#[from] reqwest::Error), + #[error(transparent)] + Session(#[from] tower_sessions::session::Error), + #[error("{0:?}")] + Auth(String), #[error("{0:?}")] Custom(String), @@ -32,12 +40,20 @@ impl From for Error { } impl IntoResponse for Error { - fn into_response(self) -> axum::response::Response { + fn into_response(self) -> Response { match self { Self::ValidationErrors(_) | Self::ValidationError(_) => { - tracing::info!("Bad request: - {self:?}"); + tracing::warn!("Bad request: - {self:?}"); StatusCode::BAD_REQUEST.into_response() } + Self::Auth(_) => { + tracing::warn!("Unauthorized : - {self:?}"); + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("WWW-Authenticate", r#"Basic realm="publish""#) + .body(Body::empty()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } _ => { tracing::error!("Internal Server: - {self:?}"); StatusCode::INTERNAL_SERVER_ERROR.into_response() diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs new file mode 100644 index 0000000..eff799e --- /dev/null +++ b/src/handlers/admin/mod.rs @@ -0,0 +1,37 @@ +use crate::{Result, model::ModelManager, session_state::TypedSession}; +use axum::{ + extract::State, + response::{Html, IntoResponse, Redirect}, +}; +use reqwest::StatusCode; +use std::sync::Arc; + +pub async fn admin_dashboard( + State(mm): State>, + session: TypedSession, +) -> Result { + let username = match session.get_user_id().await { + Ok(Some(user_id)) => mm.get_username(user_id).await?, + reason => { + tracing::error!("Failed to authenticate: {reason:?}"); + return Ok(Redirect::to("/login").into_response()); + } + }; + + let body = format!( + r#" + + + + + Admin dashboard + + +

Welcome {username}

+ + + "# + ); + + Ok((StatusCode::OK, Html(body)).into_response()) +} diff --git a/src/handlers/home/home.html b/src/handlers/home/home.html new file mode 100644 index 0000000..0e43784 --- /dev/null +++ b/src/handlers/home/home.html @@ -0,0 +1,10 @@ + + + + + Home + + +

Welcome to our newsletter!

+ + diff --git a/src/handlers/home/mod.rs b/src/handlers/home/mod.rs new file mode 100644 index 0000000..f73328e --- /dev/null +++ b/src/handlers/home/mod.rs @@ -0,0 +1,7 @@ +use crate::Result; +use axum::response::{Html, IntoResponse}; +use reqwest::StatusCode; + +pub async fn home() -> Result { + Ok((StatusCode::OK, Html(include_str!("home.html")))) +} diff --git a/src/handlers/login/get.rs b/src/handlers/login/get.rs new file mode 100644 index 0000000..364dece --- /dev/null +++ b/src/handlers/login/get.rs @@ -0,0 +1,49 @@ +use crate::Result; +use axum::response::{Html, IntoResponse}; +use axum_messages::Messages; +use reqwest::StatusCode; + +pub async fn login( + messages: Messages, + // Query(query): Option>, +) -> Result { + let error_message = messages + .into_iter() + .map(|message| format!("

{}

", message.message)) + .collect::>() + .join(""); + + let body = format!( + r#" + + + + + Login + + + {error_message} +
+ + + +
+ + + "# + ); + + Ok((StatusCode::OK, Html(body))) +} diff --git a/src/handlers/login/login.html b/src/handlers/login/login.html new file mode 100644 index 0000000..46a3b8a --- /dev/null +++ b/src/handlers/login/login.html @@ -0,0 +1,31 @@ + + + + + + Login + + +
+ + + + + +
+ + diff --git a/src/handlers/login/mod.rs b/src/handlers/login/mod.rs new file mode 100644 index 0000000..69e599d --- /dev/null +++ b/src/handlers/login/mod.rs @@ -0,0 +1,2 @@ +pub mod get; +pub mod post; diff --git a/src/handlers/login/post.rs b/src/handlers/login/post.rs new file mode 100644 index 0000000..64b084e --- /dev/null +++ b/src/handlers/login/post.rs @@ -0,0 +1,48 @@ +use crate::{ + Error, Result, handlers::Credentials, model::ModelManager, session_state::TypedSession, +}; +use axum::{ + Form, + extract::State, + response::{IntoResponse, Redirect}, +}; +use axum_messages::Messages; +use secrecy::SecretString; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct FormData { + username: String, + password: SecretString, +} + +pub async fn login( + State(mm): State>, + messages: Messages, + session: TypedSession, + Form(form): Form, +) -> Result { + let credentials = Credentials { + username: form.username, + password: form.password, + }; + + let result = mm + .validate_credientials(credentials) + .await + .map_err(|err| Error::Auth(err.to_string())); + + match result { + Ok(record_id) => { + session.renew().await?; + session.insert_user_id(record_id).await?; + Ok(Redirect::to("/admin/dashboard")) + } + Err(err) => { + tracing::warn!("Login failed! because: {err:?}"); + messages.warning("Authentication Failed"); + Ok(Redirect::to("/login")) + } + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e605bb0..a1ec109 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,7 +1,12 @@ +mod admin; mod health_check; +mod home; +pub mod login; mod newsletter; mod subscription; +pub use admin::*; pub use health_check::*; +pub use home::*; pub use newsletter::*; pub use subscription::*; diff --git a/src/handlers/newsletter.rs b/src/handlers/newsletter.rs index f0aca77..eb778ad 100644 --- a/src/handlers/newsletter.rs +++ b/src/handlers/newsletter.rs @@ -1,9 +1,10 @@ -use std::sync::Arc; - -use crate::{Result, email_client::EmailClient, model::ModelManager}; -use axum::{Json, extract::State, response::IntoResponse}; -use reqwest::StatusCode; +use crate::{Error, Result, email_client::EmailClient, model::ModelManager}; +use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use reqwest::{StatusCode, header::AUTHORIZATION}; +use secrecy::SecretString; use serde::Deserialize; +use std::sync::Arc; #[derive(Debug, Deserialize)] pub struct BodyData { @@ -21,8 +22,14 @@ struct Content { pub async fn publish_newsletter( State(mm): State>, State(email_client): State>, + headers: HeaderMap, Json(body): Json, ) -> Result { + let credentials = basic_authentication(headers).await?; + _ = mm + .validate_credientials(credentials) + .await + .map_err(|err| Error::Auth(err.to_string()))?; let subscribers = mm.get_confirmed_subscribers().await?; for subscriber in subscribers { @@ -38,3 +45,48 @@ pub async fn publish_newsletter( Ok(StatusCode::OK) } + +pub struct Credentials { + pub username: String, + pub password: SecretString, +} + +pub async fn basic_authentication(headers: HeaderMap) -> Result { + let authorization_header = headers + .get(AUTHORIZATION) + .ok_or(Error::Auth("The `Authorization` header is messing!".into()))? + .to_str() + .map_err(|err| Error::Auth(err.to_string()))?; + + let base64encoded_segment = authorization_header + .strip_prefix("Basic ") + .ok_or(Error::Auth( + "The Authrization schema is not `Basic` ?".into(), + ))?; + + let decoded_bytes = BASE64_STANDARD + .decode(base64encoded_segment) + .map_err(|err| Error::Auth(err.to_string()))?; + + let decoded_credentials = + String::from_utf8(decoded_bytes).map_err(|err| Error::Auth(err.to_string()))?; + + let mut credentials = decoded_credentials.splitn(2, ":"); + let username = credentials + .next() + .ok_or(Error::Auth( + "A username must be provided in 'Basic' Auth.".into(), + ))? + .to_string(); + let password = credentials + .next() + .ok_or(Error::Auth( + "A password must be provided in 'Basic' Auth.".into(), + ))? + .to_string(); + + Ok(Credentials { + username, + password: SecretString::new(password.into()), + }) +} diff --git a/src/lib.rs b/src/lib.rs index 215f26b..7dbc7b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ mod email_client; mod errors; mod handlers; mod model; +mod session_state; mod startup; mod state; diff --git a/src/model.rs b/src/model.rs index 25456b0..c86daeb 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,8 +1,8 @@ -use crate::{Error, Result, config::DatabaseConfig, domain}; +use crate::{Error, Result, config::DatabaseConfig, domain, handlers::Credentials}; use include_dir::include_dir; use secrecy::ExposeSecret; use serde::Deserialize; -use surrealdb::{Surreal, engine::any::Any, opt::auth::Database}; +use surrealdb::{RecordId, Surreal, engine::any::Any, opt::auth::Database}; use surrealdb_migrations::MigrationRunner; use tokio::sync::OnceCell; @@ -92,6 +92,39 @@ impl ModelManager { .take(0)?) } + pub async fn validate_credientials(&self, credentials: Credentials) -> Result { + #[derive(Debug, Deserialize)] + struct QueryResult { + id: RecordId, + } + let result = self + .db() + .await? + .query( + r#" + SELECT id + FROM ONLY users + WHERE username = $username AND crypto::argon2::compare(password, $password); + "#, + ) + .bind(("username", credentials.username)) + .bind(("password", credentials.password.expose_secret().to_string())) + .await? + .take::>(0)? + .ok_or(Error::Custom("User not found!".into()))?; + Ok(result.id) + } + + pub async fn get_username(&self, id: RecordId) -> Result { + self.db() + .await? + .query(r#"SELECT VALUE username FROM ONLY $recordId"#) + .bind(("recordId", id)) + .await? + .take::>(0)? + .ok_or(Error::Custom("User with this id don't exists".into())) + } + async fn connect(&self) -> Result> { let config = &self.config; let db = Surreal::::init(); diff --git a/src/session_state.rs b/src/session_state.rs new file mode 100644 index 0000000..73ed112 --- /dev/null +++ b/src/session_state.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponse, Response}, +}; +use reqwest::StatusCode; +use surrealdb::RecordId; +use tower_sessions::Session; + +use crate::Result; + +pub struct TypedSession(Session); + +impl TypedSession { + const USER_ID_KEY: &str = "user_id"; + + pub async fn renew(&self) -> Result<()> { + Ok(self.0.cycle_id().await?) + } + + pub async fn insert_user_id(&self, user_id: RecordId) -> Result<()> { + Ok(self.0.insert(Self::USER_ID_KEY, user_id).await?) + } + + pub async fn get_user_id(&self) -> Result> { + Ok(self.0.get(Self::USER_ID_KEY).await?) + } +} + +impl FromRequestParts for TypedSession +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|err| { + tracing::error!("Something went wrong!: {err:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to extract session", + ) + .into_response() + })?; + + Ok(Self(session)) + } +} diff --git a/src/startup.rs b/src/startup.rs index 81acae8..3e28bc3 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,7 +1,7 @@ use crate::{ Result, config::Config, - handlers::{confirm, health, publish_newsletter, subscribe}, + handlers::{admin_dashboard, confirm, health, home, login, publish_newsletter, subscribe}, state::AppState, }; use axum::{ @@ -9,11 +9,15 @@ use axum::{ http::{HeaderName, Request}, routing::{get, post}, }; +use axum_messages::MessagesManagerLayer; use tower::ServiceBuilder; +use tower_cookies::CookieManagerLayer; use tower_http::{ request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, trace::TraceLayer, }; +use tower_sessions::SessionManagerLayer; +use tower_sessions_surrealdb_store::SurrealSessionStore; const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); @@ -30,13 +34,20 @@ pub async fn init(config: Config) -> Result<(Router, AppState)> { }), ) // send headers from request to response headers - .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER)); + .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER)) + .layer(CookieManagerLayer::new()) + .layer(SessionManagerLayer::new(SurrealSessionStore::new(state.mm.db().await?.clone(), "sessions".into()))) + .layer(MessagesManagerLayer); let router = Router::new() + .route("/", get(home)) .route("/health", get(health)) .route("/subscriptions", post(subscribe)) .route("/subscriptions/confirm", get(confirm)) .route("/newsletter", post(publish_newsletter)) + .route("/login", get(login::get::login)) + .route("/login", post(login::post::login)) + .route("/admin/dashboard", get(admin_dashboard)) .layer(middleware) .with_state(state.clone()); diff --git a/surrealdb/migrations/definitions/_initial.json b/surrealdb/migrations/definitions/_initial.json index 7bfae32..65d3986 100644 --- a/surrealdb/migrations/definitions/_initial.json +++ b/surrealdb/migrations/definitions/_initial.json @@ -1 +1 @@ -{"schemas":"DEFINE TABLE OVERWRITE script_migration SCHEMAFULL\n PERMISSIONS\n FOR select FULL\n FOR create, update, delete NONE;\n\nDEFINE FIELD OVERWRITE script_name ON script_migration TYPE string;\nDEFINE FIELD OVERWRITE executed_at ON script_migration TYPE datetime VALUE time::now() READONLY;\nDEFINE FIELD OVERWRITE checksum ON script_migration TYPE option;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscription_tokens SCHEMAFULL\nCOMMENT 'Subscription Tokens table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE token ON subscription_tokens TYPE string;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscription_tokens TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_token ON subscription_tokens COLUMNS token UNIQUE;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscriptions SCHEMAFULL\nCOMMENT 'Subscription table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE email ON subscriptions TYPE string ASSERT string::is::email($value);\nDEFINE FIELD OVERWRITE name ON subscriptions TYPE string;\nDEFINE FIELD OVERWRITE status ON subscriptions TYPE 'PENDING' | 'CONFIRMED' DEFAULT 'PENDING';\nDEFINE FIELD OVERWRITE token ON subscriptions TYPE record;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscriptions TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_email ON subscriptions COLUMNS email UNIQUE;\n","events":""} \ No newline at end of file +{"schemas":"DEFINE TABLE OVERWRITE script_migration SCHEMAFULL\n PERMISSIONS\n FOR select FULL\n FOR create, update, delete NONE;\n\nDEFINE FIELD OVERWRITE script_name ON script_migration TYPE string;\nDEFINE FIELD OVERWRITE executed_at ON script_migration TYPE datetime VALUE time::now() READONLY;\nDEFINE FIELD OVERWRITE checksum ON script_migration TYPE option;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE sessions SCHEMALESS\nCOMMENT 'Sessions table';\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscription_tokens SCHEMAFULL\nCOMMENT 'Subscription Tokens table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE token ON subscription_tokens TYPE string;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscription_tokens TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_token ON subscription_tokens COLUMNS token UNIQUE;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscriptions SCHEMAFULL\nCOMMENT 'Subscription table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE email ON subscriptions TYPE string ASSERT string::is::email($value);\nDEFINE FIELD OVERWRITE name ON subscriptions TYPE string;\nDEFINE FIELD OVERWRITE status ON subscriptions TYPE 'PENDING' | 'CONFIRMED' DEFAULT 'PENDING';\nDEFINE FIELD OVERWRITE token ON subscriptions TYPE record;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscriptions TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_email ON subscriptions COLUMNS email UNIQUE;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE users SCHEMAFULL\nCOMMENT 'Users table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE username ON users TYPE string;\nDEFINE FIELD OVERWRITE password ON users TYPE string;\nDEFINE FIELD OVERWRITE created_at ON TABLE users TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE username ON users COLUMNS username UNIQUE;\n","events":""} \ No newline at end of file diff --git a/surrealdb/schemas/sessions.surql b/surrealdb/schemas/sessions.surql new file mode 100644 index 0000000..1ebe6be --- /dev/null +++ b/surrealdb/schemas/sessions.surql @@ -0,0 +1,3 @@ +# --- TABLE --- +DEFINE TABLE OVERWRITE sessions SCHEMALESS +COMMENT 'Sessions table'; diff --git a/surrealdb/schemas/users.surql b/surrealdb/schemas/users.surql new file mode 100644 index 0000000..db04fbf --- /dev/null +++ b/surrealdb/schemas/users.surql @@ -0,0 +1,11 @@ +# --- TABLE --- +DEFINE TABLE OVERWRITE users SCHEMAFULL +COMMENT 'Users table'; + +# --- FIELDS --- +DEFINE FIELD OVERWRITE username ON users TYPE string; +DEFINE FIELD OVERWRITE password ON users TYPE string; +DEFINE FIELD OVERWRITE created_at ON TABLE users TYPE datetime VALUE time::now() READONLY; + +# --- INDEXES --- +DEFINE INDEX OVERWRITE username ON users COLUMNS username UNIQUE; diff --git a/tests/api/admin_dashboard.rs b/tests/api/admin_dashboard.rs new file mode 100644 index 0000000..eee78f2 --- /dev/null +++ b/tests/api/admin_dashboard.rs @@ -0,0 +1,16 @@ +use reqwest::{StatusCode, header}; + +use crate::helpers::TestApp; + +#[tokio::test] +async fn you_must_be_logged_to_access_admin_dashboard() { + // Arrange + let app = TestApp::new().await.expect("Failed to start test app"); + + // Act + let response = app.server.get("/admin/dashboard").await; + + // Assert + assert_eq!(response.status_code(), StatusCode::SEE_OTHER); + assert_eq!(response.header(header::LOCATION), "/login"); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index faf9b57..3f60153 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,6 +1,5 @@ -use std::str::FromStr; - use axum_test::TestServer; +use std::str::FromStr; use subscriptions::{AppState, Config}; use tokio::sync::OnceCell; use tracing_subscriber::prelude::*; @@ -9,10 +8,16 @@ use wiremock::MockServer; pub type Result = std::result::Result>; +pub struct Credentials { + pub username: String, + pub password: String, +} + pub struct TestApp { pub server: TestServer, pub state: AppState, pub email_server: MockServer, + pub test_user: Credentials, } pub struct ConfirmationLinks { @@ -41,12 +46,38 @@ impl TestApp { let email_server = MockServer::start().await; config.email_client.base_url = Url::from_str(&email_server.uri())?; + let test_user = Credentials { + password: "password".into(), + username: "username".into(), + }; let (router, state) = subscriptions::init(config).await?; + // create test user for valid authentications + state + .mm + .db() + .await + .unwrap() + .query( + r#" + INSERT INTO users { + username: $username, + password: crypto::argon2::generate($password) + } + "#, + ) + .bind(("username", test_user.username.clone())) + .bind(("password", test_user.password.clone())) + .await + .unwrap() + .check() + .unwrap(); + Ok(TestApp { - server: TestServer::new(router)?, + server: TestServer::builder().save_cookies().build(router)?, state, email_server, + test_user, }) } diff --git a/tests/api/login.rs b/tests/api/login.rs new file mode 100644 index 0000000..d7a757d --- /dev/null +++ b/tests/api/login.rs @@ -0,0 +1,67 @@ +use crate::helpers::TestApp; +use reqwest::{StatusCode, header::LOCATION}; +use serde_json::json; + +#[tokio::test] +async fn an_error_flash_message_is_set_on_failure() { + // Arrenge + let app = TestApp::new() + .await + .expect("Expected the app to be inissilized!"); + + // Act + let response = app + .server + .post("/login") + .form(&json!({ + "username": "random-username", + "password": "random-password", + })) + .await; + + // Assert + assert_eq!(response.status_code(), StatusCode::SEE_OTHER); + assert_eq!(response.header(LOCATION), "/login"); + let html_page = app.server.get("/login").await; + assert!( + html_page + .text() + .contains(r#"

Authentication Failed

"#) + ); + + // reload the page + let html_page = app.server.get("/login").await; + assert!( + !html_page + .text() + .contains(r#"

Authentication Failed

"#) + ); +} + +#[tokio::test] +async fn redirect_to_admin_dashboard_after_login_login_success() { + // Arrenge + let app = TestApp::new() + .await + .expect("Expected the app to be inissilized!"); + + // Act + let response = app + .server + .post("/login") + .form(&json!({ + "username": app.test_user.username, + "password": app.test_user.password, + })) + .await; + + // Assert + assert_eq!(response.status_code(), StatusCode::SEE_OTHER); + assert_eq!(response.header(LOCATION), "/admin/dashboard"); + let response = app.server.get("/admin/dashboard").await; + assert!( + response + .text() + .contains(&format!("Welcome {}", app.test_user.username)) + ); +} diff --git a/tests/api/main.rs b/tests/api/main.rs index 43409e1..5d0d189 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,5 +1,7 @@ +mod admin_dashboard; mod health_check; mod helpers; +mod login; mod newsletter; mod subscriptions; mod subscriptions_confirm; diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs index 958c00e..ecb3f16 100644 --- a/tests/api/newsletter.rs +++ b/tests/api/newsletter.rs @@ -1,3 +1,4 @@ +use base64::{Engine, prelude::BASE64_STANDARD}; use reqwest::{Method, StatusCode}; use serde_json::json; use wiremock::{ @@ -5,7 +6,7 @@ use wiremock::{ matchers::{any, method}, }; -use crate::helpers::{ConfirmationLinks, TestApp}; +use crate::helpers::{ConfirmationLinks, Credentials, TestApp}; async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { let body = [("name", "let guin"), ("email", "ursula_le_guin@gmail.com")]; @@ -42,6 +43,13 @@ async fn create_confirmed_subscriber(app: &TestApp) { .assert_status_success(); } +fn get_basic_authorization_header(user: &Credentials) -> String { + format!( + "Basic {}", + BASE64_STANDARD.encode(format!("{}:{}", user.username, user.password)) + ) +} + #[tokio::test] async fn newsletter_are_not_delivered_to_unconfirmed_subscribers() { // Arrange @@ -58,7 +66,12 @@ async fn newsletter_are_not_delivered_to_unconfirmed_subscribers() { "html": "

Newsletter body as html

", }, }); - let response = app.server.post("/newsletter").json(&newsletter).await; + let response = app + .server + .post("/newsletter") + .authorization(get_basic_authorization_header(&app.test_user)) + .json(&newsletter) + .await; // Assert assert_eq!(response.status_code(), StatusCode::OK); @@ -87,7 +100,12 @@ async fn newsletter_are_delivered_to_confirmed_subscribers() { "html": "

Newsletter body as html

", }, }); - let response = app.server.post("/newsletter").json(&newsletter).await; + let response = app + .server + .post("/newsletter") + .authorization(get_basic_authorization_header(&app.test_user)) + .json(&newsletter) + .await; // Assert assert_eq!(response.status_code(), StatusCode::OK); @@ -119,7 +137,12 @@ async fn newsletter_return_400_for_invalid_data() { for (invalid_body, error_message) in test_cases { // Act - let response = app.server.post("/newsletter").json(&invalid_body).await; + let response = app + .server + .post("/newsletter") + .authorization(get_basic_authorization_header(&app.test_user)) + .json(&invalid_body) + .await; // Assert assert_eq!( @@ -130,3 +153,95 @@ async fn newsletter_return_400_for_invalid_data() { ); } } + +#[tokio::test] +async fn requests_messing_authorization_are_rejected() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected the app to be inisilized!"); + + // Act + let response = app + .server + .post("/newsletter") + .json(&json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as html

", + }, + })) + .await; + + // Assert + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + assert_eq!( + r#"Basic realm="publish""#, + response.header("WWW-Authenticate") + ); +} + +#[tokio::test] +async fn non_existing_user_is_rejected() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected the app to be inisilized!"); + + // Act + let response = app + .server + .post("/newsletter") + .authorization(get_basic_authorization_header(&Credentials { + username: "what".into(), + password: "password-non".into(), + })) + .json(&json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as html

", + }, + })) + .await; + + // Assert + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + assert_eq!( + r#"Basic realm="publish""#, + response.header("WWW-Authenticate") + ); +} + +#[tokio::test] +async fn invalid_password_is_rejected() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected the app to be inisilized!"); + + // Act + let response = app + .server + .post("/newsletter") + .authorization(get_basic_authorization_header(&Credentials { + username: app.test_user.username.clone(), + password: "invalid-password".into(), + })) + .json(&json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as html

", + }, + })) + .await; + + // Assert + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + assert_eq!( + r#"Basic realm="publish""#, + response.header("WWW-Authenticate") + ); +}