From ff75a11b5e14a6a680adda646eb47925d27bf52c Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Mon, 28 Jul 2025 20:06:14 +0100 Subject: [PATCH 1/4] feat: Verify Subscriber --- src/http/mod.rs | 2 + src/http/newsletter/domain.rs | 38 ++++++ src/http/newsletter/mod.rs | 13 ++ src/http/newsletter/verify_subscriber.rs | 134 +++++++++++++++++++ tests/api/main.rs | 1 + tests/api/newsletter.rs | 160 +++++++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 src/http/newsletter/domain.rs create mode 100644 src/http/newsletter/mod.rs create mode 100644 src/http/newsletter/verify_subscriber.rs create mode 100644 tests/api/newsletter.rs diff --git a/src/http/mod.rs b/src/http/mod.rs index 05d500e0..3f2a5697 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -14,6 +14,7 @@ pub type Result = std::result::Result; mod escrow; mod health_check; +mod newsletter; mod project; mod support_ticket; mod transaction; @@ -53,6 +54,7 @@ pub fn api_router(app_state: AppState) -> Router { .merge(project::router()) .merge(support_ticket::router()) .merge(escrow::router()) + .merge(newsletter::router()) .layer(trace_layer) .layer(request_id_layer) .layer(propagate_request_id_layer) diff --git a/src/http/newsletter/domain.rs b/src/http/newsletter/domain.rs new file mode 100644 index 00000000..178f6283 --- /dev/null +++ b/src/http/newsletter/domain.rs @@ -0,0 +1,38 @@ +use garde::Validate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, sqlx::Type, serde::Serialize)] +#[sqlx(type_name = "subscriber_status", rename_all = "lowercase")] +pub enum SubscriberStatus { + Pending, + Active, + Unsubscribed, + Bounced, + SpamComplaint, +} + +#[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, +} + +#[derive(Debug, Serialize)] +pub struct NewsletterSubscriber { + pub id: Uuid, + pub email: String, + pub name: String, + pub status: SubscriberStatus, + pub subscribed_at: Option>, + pub created_at: chrono::DateTime, + pub updated_at: Option>, +} diff --git a/src/http/newsletter/mod.rs b/src/http/newsletter/mod.rs new file mode 100644 index 00000000..aca519de --- /dev/null +++ b/src/http/newsletter/mod.rs @@ -0,0 +1,13 @@ +mod domain; +mod verify_subscriber; + +use axum::{Router, routing::post}; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new().route( + "/newsletter/verify", + post(verify_subscriber::verify_subscriber), + ) +} 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/main.rs b/tests/api/main.rs index 5e61eace..b7d69542 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -3,6 +3,7 @@ mod create_project; mod escrow; mod health_check; mod helpers; +mod newsletter; mod projects; mod support_tickets; mod transaction; diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs new file mode 100644 index 00000000..e64f1a63 --- /dev/null +++ b/tests/api/newsletter.rs @@ -0,0 +1,160 @@ +use crate::helpers::TestApp; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +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); +} From da65a64fc6abd2fb423a7e6b35077c58e3092ce8 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Wed, 30 Jul 2025 05:45:12 +0100 Subject: [PATCH 2/4] fix: cargo clippy --- ...ae4ebf5ea33b7f6c9c99db8588ff79d8af0bc.json | 15 ++++ ...097cd3bb7e3c3227a4130dc5273cb3999ace1.json | 71 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 .sqlx/query-48830c12fec186883495535f2beae4ebf5ea33b7f6c9c99db8588ff79d8af0bc.json create mode 100644 .sqlx/query-900f50b9fdbdb54ed5d09227041097cd3bb7e3c3227a4130dc5273cb3999ace1.json 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" +} From 4e189df113bb7d4670807890f99a0e32967ab208 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Wed, 30 Jul 2025 13:50:20 +0100 Subject: [PATCH 3/4] fix: merge conflicts --- src/http/mod.rs | 8 ++++---- src/http/newsletter/domain.rs | 7 ++++++- src/http/newsletter/mod.rs | 2 +- tests/api/newsletter.rs | 21 +++++++++++++++------ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/http/mod.rs b/src/http/mod.rs index 00bbda86..5212af92 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -45,7 +45,7 @@ pub fn api_router(app_state: AppState) -> Router { let cors_layer = cors_layer(); let timeout_layer = timeout_layer(); let normalize_path_layer = normalize_path_layer(); - + Router::new() .merge(health_check::router()) .merge(project::router()) @@ -68,7 +68,7 @@ async fn shutdown_signal() { .await .expect("failed to configure ctrl+c handler"); }; - + #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) @@ -76,10 +76,10 @@ async fn shutdown_signal() { .recv() .await; }; - + #[cfg(not(unix))] let terminate = std::future::pending::<()>(); - + tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, diff --git a/src/http/newsletter/domain.rs b/src/http/newsletter/domain.rs index 376e1b98..fa6330f3 100644 --- a/src/http/newsletter/domain.rs +++ b/src/http/newsletter/domain.rs @@ -2,7 +2,7 @@ use garde::Validate; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, sqlx::Type, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, sqlx::Type, serde::Serialize, serde::Deserialize)] #[sqlx(type_name = "subscriber_status", rename_all = "lowercase")] pub enum SubscriberStatus { Pending, @@ -14,14 +14,19 @@ pub enum SubscriberStatus { #[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>, } diff --git a/src/http/newsletter/mod.rs b/src/http/newsletter/mod.rs index 7ffe84a6..a0bef9c7 100644 --- a/src/http/newsletter/mod.rs +++ b/src/http/newsletter/mod.rs @@ -2,8 +2,8 @@ pub mod domain; pub mod subscribe; mod verify_subscriber; -use axum::{Router, routing::post}; use crate::AppState; +use axum::{Router, routing::post}; pub fn router() -> Router { Router::new() diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs index 70c7329a..7c4df7a7 100644 --- a/tests/api/newsletter.rs +++ b/tests/api/newsletter.rs @@ -3,7 +3,7 @@ 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; @@ -180,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] From 5a2284175fc5adf7d3dbfcdc65ece7ae7663a786 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Wed, 30 Jul 2025 14:01:49 +0100 Subject: [PATCH 4/4] fix: failing tests --- src/http/newsletter/domain.rs | 8 ++++++++ src/http/newsletter/subscribe.rs | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/http/newsletter/domain.rs b/src/http/newsletter/domain.rs index fa6330f3..bdc5245f 100644 --- a/src/http/newsletter/domain.rs +++ b/src/http/newsletter/domain.rs @@ -30,6 +30,14 @@ pub struct NewsletterSubscriber { 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))] 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()?;