//! Signup and notify mailing-list tests: storing an address, the unsubscribe //! link, and resubscribing. //! //! Split out of `pages` because the subject is the mailing list's lifecycle //! rather than whether a route renders. The form lives on a page; what these //! assert is what happens to the address afterwards. use crate::harness::TestHarness; /// The form works with JS off. /// /// It had no action and no method, so a browser without JS submitted a GET to /// `/` and the address was dropped silently. This is the only email capture on /// the landing page, so a silent loss is a lost signup nobody can count. #[tokio::test] async fn notify_form_without_js_stores_the_address() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; let resp = h .client .post_form("/notify", "email=nojs@example.com") .await; assert_eq!( resp.status, 303, "expected a redirect back to the landing page" ); let location = resp .headers .get("location") .and_then(|v| v.to_str().ok()) .unwrap_or_default(); assert!( location.starts_with("/?notify=ok"), "redirected to {location}" ); let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") .bind("nojs@example.com") .fetch_one(&h.db) .await .expect("count signups"); assert_eq!(stored, 1, "the address never reached storage"); } /// A rejected address comes back as a sentence on the page, not a 422. This is /// the last thing on the landing page and an error code is a worse outcome /// than being told the address looked wrong. #[tokio::test] async fn notify_form_rejects_a_bad_address_without_erroring() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; let resp = h.client.post_form("/notify", "email=not-an-address").await; assert_eq!(resp.status, 303); assert!( resp.headers .get("location") .and_then(|v| v.to_str().ok()) .unwrap_or_default() .starts_with("/?notify=invalid") ); let landing = h.client.get("/?notify=invalid").await; assert!( landing.text.contains("That address didn't look right."), "the landing page said nothing about the rejection" ); } /// The success message renders for a no-JS visitor coming back off the /// redirect, and not on a plain page load. #[tokio::test] async fn notify_status_renders_only_when_the_redirect_says_so() { let mut h = TestHarness::new().await; let plain = h.client.get("/").await; assert!( !plain.text.contains("You're on the list."), "status shown on a plain load" ); let after = h.client.get("/?notify=ok").await; assert!(after.text.contains("You're on the list.")); } // ── Landing signup unsubscribe ── // // Step 1 of wiki [[mnw-mailing-lists]]. The table had insert, admin read and // count, and no way out. Withdrawal has to be as easy as consent, and consent // is one form field. fn signup_unsub_url(email: &str) -> String { makenotwork::email::generate_signup_unsubscribe_url( "", email, "test-signing-secret-for-integration-tests", ) } /// The signed link unsubscribes, and the address stops being mailable. #[tokio::test] async fn signup_unsubscribe_link_removes_the_address_from_the_mailable_list() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=leaving@example.com") .await; let before: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("leaving@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(before, 1, "signup did not land"); let resp = h.client.get(&signup_unsub_url("leaving@example.com")).await; assert_eq!(resp.status, 200); let after: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("leaving@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(after, 0, "still mailable after unsubscribing"); // Marked, not deleted: a deleted address is one the next import re-adds. let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") .bind("leaving@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(retained, 1, "the opt-out record was thrown away"); } /// RFC 8058: a POST to the same URL unsubscribes with no confirmation step, /// which is what the List-Unsubscribe-Post header promises Gmail and Yahoo. /// Retries must not fail, so the second POST answers the same as the first. #[tokio::test] async fn signup_unsubscribe_one_click_post_is_idempotent() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=oneclick@example.com") .await; let url = signup_unsub_url("oneclick@example.com"); let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; assert_eq!(first.status, 200); let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; assert_eq!(second.status, 200, "a retried one-click POST must not fail"); } /// A tampered signature does nothing. The address is signed over, so a token /// for one address cannot unsubscribe another. #[tokio::test] async fn signup_unsubscribe_rejects_a_forged_link() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=victim@example.com") .await; // Take a valid token for one address and point it at another. let forged = signup_unsub_url("attacker@example.com") .replace("attacker%40example.com", "victim%40example.com"); let resp = h.client.get(&forged).await; assert!( resp.text.contains("Invalid Link"), "a forged link was accepted" ); let still_mailable: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("victim@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(still_mailable, 1, "a forged link unsubscribed someone"); } /// Signing up again after unsubscribing re-subscribes. The form is the only /// interface, so refusing would leave someone unable to opt back in. #[tokio::test] async fn signing_up_again_after_unsubscribing_restores_the_subscription() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=returning@example.com") .await; h.client .get(&signup_unsub_url("returning@example.com")) .await; h.client .post_form("/notify", "email=returning@example.com") .await; let mailable: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("returning@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(mailable, 1, "could not opt back in"); }