diff --git a/.sqlx/query-48830c12fec186883495535f2beae4ebf5ea33b7f6c9c99db8588ff79d8af0bc.json b/.sqlx/query-48830c12fec186883495535f2beae4ebf5ea33b7f6c9c99db8588ff79d8af0bc.json new file mode 100644 index 00000000..3f0f6830 --- /dev/null +++ b/.sqlx/query-48830c12fec186883495535f2beae4ebf5ea33b7f6c9c99db8588ff79d8af0bc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE newsletter_subscribers\n SET\n status = 'active',\n subscribed_at = $2,\n updated_at = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "48830c12fec186883495535f2beae4ebf5ea33b7f6c9c99db8588ff79d8af0bc" +} diff --git a/.sqlx/query-900f50b9fdbdb54ed5d09227041097cd3bb7e3c3227a4130dc5273cb3999ace1.json b/.sqlx/query-900f50b9fdbdb54ed5d09227041097cd3bb7e3c3227a4130dc5273cb3999ace1.json new file mode 100644 index 00000000..7009aa9b --- /dev/null +++ b/.sqlx/query-900f50b9fdbdb54ed5d09227041097cd3bb7e3c3227a4130dc5273cb3999ace1.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n ns.id,\n ns.email,\n ns.name,\n ns.status as \"status!: String\",\n ns.subscribed_at,\n ns.created_at,\n ns.updated_at\n FROM newsletter_subscribers ns\n INNER JOIN subscription_token st ON ns.id = st.subscriber_id\n WHERE st.subscription_token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "status!: String", + "type_info": { + "Custom": { + "name": "subscriber_status", + "kind": { + "Enum": [ + "pending", + "active", + "unsubscribed", + "bounced", + "spam_complaint" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "subscribed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "900f50b9fdbdb54ed5d09227041097cd3bb7e3c3227a4130dc5273cb3999ace1" +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 3d6d525d..5212af92 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -29,9 +29,7 @@ pub struct AppState { pub async fn serve(configuration: Arc, db: Db) -> anyhow::Result<()> { let addr = configuration.listen_address; let app_state = AppState { configuration, db }; - let app = api_router(app_state); - tracing::info!("Listening for requests on {}", addr); let listener = TcpListener::bind(addr).await?; axum::serve(listener, app) @@ -82,5 +80,8 @@ async fn shutdown_signal() { #[cfg(not(unix))] let terminate = std::future::pending::<()>(); - tokio::select! {_ = ctrl_c => {}, _ = terminate => {},} + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } } diff --git a/src/http/newsletter/domain.rs b/src/http/newsletter/domain.rs index 539fb5de..bdc5245f 100644 --- a/src/http/newsletter/domain.rs +++ b/src/http/newsletter/domain.rs @@ -1,10 +1,53 @@ use garde::Validate; use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, sqlx::Type, serde::Serialize, serde::Deserialize)] +#[sqlx(type_name = "subscriber_status", rename_all = "lowercase")] +pub enum SubscriberStatus { + Pending, + Active, + Unsubscribed, + Bounced, + SpamComplaint, +} #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct NewsletterSubscriber { + #[garde(skip)] + pub id: Uuid, #[garde(email)] pub email: String, #[garde(length(min = 2, max = 255))] pub name: String, + #[garde(skip)] + pub status: SubscriberStatus, + #[garde(skip)] + pub subscribed_at: Option>, + #[garde(skip)] + pub created_at: chrono::DateTime, + #[garde(skip)] + pub updated_at: Option>, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct SubscribeNewsletterRequest { + #[garde(email)] + pub email: String, + #[garde(length(min = 2, max = 255))] + pub name: String, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct VerifySubscriberRequest { + #[garde(ascii, length(min = 1))] + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct VerifySubscriberResponse { + pub message: String, + pub subscriber_id: Uuid, + pub email: String, + pub verified_at: chrono::DateTime, } diff --git a/src/http/newsletter/mod.rs b/src/http/newsletter/mod.rs index 40a7aace..a0bef9c7 100644 --- a/src/http/newsletter/mod.rs +++ b/src/http/newsletter/mod.rs @@ -1,9 +1,15 @@ pub mod domain; pub mod subscribe; +mod verify_subscriber; use crate::AppState; -use axum::Router; +use axum::{Router, routing::post}; pub fn router() -> Router { - Router::new().nest("/newsletter", subscribe::router()) + Router::new() + .nest("/newsletter", subscribe::router()) + .route( + "/newsletter/verify", + post(verify_subscriber::verify_subscriber), + ) } diff --git a/src/http/newsletter/subscribe.rs b/src/http/newsletter/subscribe.rs index 07fa0fc7..fe184164 100644 --- a/src/http/newsletter/subscribe.rs +++ b/src/http/newsletter/subscribe.rs @@ -7,7 +7,7 @@ use axum::{ }; use garde::Validate; -use crate::{AppState, Result, http::newsletter::domain::NewsletterSubscriber}; +use crate::{AppState, Result, http::newsletter::domain::SubscribeNewsletterRequest}; pub fn router() -> Router { Router::new().route("/subscribe", post(subscribe_handler)) @@ -23,7 +23,7 @@ pub fn router() -> Router { )] pub async fn subscribe_handler( state: State, - Json(req): Json, + Json(req): Json, ) -> Result { req.validate()?; diff --git a/src/http/newsletter/verify_subscriber.rs b/src/http/newsletter/verify_subscriber.rs new file mode 100644 index 00000000..62a34a80 --- /dev/null +++ b/src/http/newsletter/verify_subscriber.rs @@ -0,0 +1,134 @@ +use crate::{ + AppState, Error, Result, + http::newsletter::domain::{ + NewsletterSubscriber, SubscriberStatus, VerifySubscriberRequest, VerifySubscriberResponse, + }, +}; +use axum::{Json, extract::State}; +use garde::Validate; +use sqlx::PgPool; +use uuid::Uuid; + +#[tracing::instrument(name = "Verify Newsletter Subscriber", skip(state))] +pub async fn verify_subscriber( + State(state): State, + Json(request): Json, +) -> Result> { + request.validate()?; + + tracing::info!( + token_length = request.token.len(), + "Attempting to verify newsletter subscriber" + ); + + // Find the subscriber by token + let subscriber = find_subscriber_by_token(&state.db.pool, &request.token).await?; + + if subscriber.status == SubscriberStatus::Active { + tracing::warn!( + subscriber_id = %subscriber.id, + "Attempt to reverify already active subscriber" + ); + return Err(Error::unprocessable_entity([( + "verification", + "Subscriber is already verified", + )])); + } + + let verification_date = chrono::Utc::now(); + + match verify_subscriber_in_db(&state.db.pool, subscriber.id, verification_date).await { + Ok(_) => { + tracing::info!("Subscriber {} successfully verified", subscriber.id); + Ok(Json(VerifySubscriberResponse { + message: "Newsletter subscription successfully verified".to_string(), + subscriber_id: subscriber.id, + email: subscriber.email, + verified_at: verification_date, + })) + } + Err(e) => { + tracing::error!("Failed to verify subscriber {}: {}", subscriber.id, e); + Err(Error::unprocessable_entity([( + "verification", + "Failed to verify subscriber", + )])) + } + } +} + +async fn find_subscriber_by_token(pool: &PgPool, token: &str) -> Result { + let result = sqlx::query!( + r#" + SELECT + ns.id, + ns.email, + ns.name, + ns.status as "status!: String", + ns.subscribed_at, + ns.created_at, + ns.updated_at + FROM newsletter_subscribers ns + INNER JOIN subscription_token st ON ns.id = st.subscriber_id + WHERE st.subscription_token = $1 + "#, + token + ) + .fetch_optional(pool) + .await?; + + match result { + Some(row) => { + let status = match row.status.as_str() { + "pending" => SubscriberStatus::Pending, + "active" => SubscriberStatus::Active, + "unsubscribed" => SubscriberStatus::Unsubscribed, + "bounced" => SubscriberStatus::Bounced, + "spam_complaint" => SubscriberStatus::SpamComplaint, + _ => { + return Err(Error::unprocessable_entity([( + "status", + "Invalid subscriber status", + )])); + } + }; + + Ok(NewsletterSubscriber { + id: row.id, + email: row.email, + name: row.name, + status, + subscribed_at: row.subscribed_at, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } + None => { + tracing::error!("No subscriber found for token"); + Err(Error::NotFound) + } + } +} + +async fn verify_subscriber_in_db( + pool: &PgPool, + subscriber_id: Uuid, + verification_date: chrono::DateTime, +) -> Result<()> { + sqlx::query!( + r#" + UPDATE newsletter_subscribers + SET + status = 'active', + subscribed_at = $2, + updated_at = $2 + WHERE id = $1 + "#, + subscriber_id, + verification_date + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs index 9e4ebbea..7c4df7a7 100644 --- a/tests/api/newsletter.rs +++ b/tests/api/newsletter.rs @@ -3,8 +3,162 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use fortichain_server::http::newsletter::domain::NewsletterSubscriber; +use fortichain_server::http::newsletter::domain::{NewsletterSubscriber, SubscriberStatus}; use serde_json::json; +use sqlx::Row; +use uuid::Uuid; + +#[tokio::test] +async fn test_verify_subscriber_success() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test subscriber + let subscriber_id = Uuid::now_v7(); + let email = "test@example.com"; + let name = "Test User"; + let token = "test-verification-token-123"; + + // Insert subscriber + sqlx::query( + r#" + INSERT INTO newsletter_subscribers (id, email, name, status) + VALUES ($1, $2, $3, 'pending') + "#, + ) + .bind(subscriber_id) + .bind(email) + .bind(name) + .execute(&db.pool) + .await + .expect("Failed to insert test subscriber"); + + // Insert verification token + sqlx::query( + r#" + INSERT INTO subscription_token (subscription_token, subscriber_id) + VALUES ($1, $2) + "#, + ) + .bind(token) + .bind(subscriber_id) + .execute(&db.pool) + .await + .expect("Failed to insert verification token"); + + let verify_request = json!({ + "token": token + }); + + let req = Request::post("/newsletter/verify") + .header("content-type", "application/json") + .body(Body::from(verify_request.to_string())) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + // Verify the subscriber status was updated + let updated_subscriber = sqlx::query( + "SELECT status::text as status, subscribed_at FROM newsletter_subscribers WHERE id = $1", + ) + .bind(subscriber_id) + .fetch_one(&db.pool) + .await + .expect("Failed to fetch updated subscriber"); + + let status: String = updated_subscriber.get("status"); + let subscribed_at: Option> = + updated_subscriber.get("subscribed_at"); + + assert_eq!(status, "active"); + assert!(subscribed_at.is_some()); +} + +#[tokio::test] +async fn test_verify_subscriber_invalid_token() { + let app = TestApp::new().await; + + let verify_request = json!({ + "token": "invalid-token" + }); + + let req = Request::post("/newsletter/verify") + .header("content-type", "application/json") + .body(Body::from(verify_request.to_string())) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_verify_subscriber_already_verified() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test subscriber that's already active + let subscriber_id = Uuid::now_v7(); + let email = "test@example.com"; + let name = "Test User"; + let token = "test-verification-token-456"; + + // Insert subscriber with active status + sqlx::query( + r#" + INSERT INTO newsletter_subscribers (id, email, name, status, subscribed_at) + VALUES ($1, $2, $3, 'active', NOW()) + "#, + ) + .bind(subscriber_id) + .bind(email) + .bind(name) + .execute(&db.pool) + .await + .expect("Failed to insert test subscriber"); + + // Insert verification token + sqlx::query( + r#" + INSERT INTO subscription_token (subscription_token, subscriber_id) + VALUES ($1, $2) + "#, + ) + .bind(token) + .bind(subscriber_id) + .execute(&db.pool) + .await + .expect("Failed to insert verification token"); + + let verify_request = json!({ + "token": token + }); + + let req = Request::post("/newsletter/verify") + .header("content-type", "application/json") + .body(Body::from(verify_request.to_string())) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_verify_subscriber_empty_token() { + let app = TestApp::new().await; + + let verify_request = json!({ + "token": "" + }); + + let req = Request::post("/newsletter/verify") + .header("content-type", "application/json") + .body(Body::from(verify_request.to_string())) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} #[tokio::test] async fn subscribe_returns_a_200_for_valid_form_data() { @@ -26,16 +180,25 @@ async fn subscribe_returns_a_200_for_valid_form_data() { // Assert assert_eq!(response.status(), StatusCode::CREATED); - let saved = sqlx::query_as!( - NewsletterSubscriber, - "SELECT email, name FROM newsletter_subscribers", + let saved = sqlx::query!( + "SELECT id, email, name, status as \"status!: SubscriberStatus\", subscribed_at, created_at, updated_at FROM newsletter_subscribers", ) .fetch_one(&app.db.pool) .await .expect("Failed to fetch saved subscriber."); - assert_eq!(saved.email, "test@example.com"); - assert_eq!(saved.name, "Test"); + let subscriber = NewsletterSubscriber { + id: saved.id, + email: saved.email, + name: saved.name, + status: saved.status, + subscribed_at: saved.subscribed_at, + created_at: saved.created_at, + updated_at: saved.updated_at, + }; + + assert_eq!(subscriber.email, "test@example.com"); + assert_eq!(subscriber.name, "Test"); } #[tokio::test]