//! The unsubscribe surface (step 4 of wiki `mnw-mailing-lists`). //! //! The preferences page and the one-click POST are the only list controls a //! subscriber ever sees, and both are reached by a signed URL rather than a //! session. What is worth pinning is that the signature is the whole of the //! authorization: a GET mutates nothing, a token names one subscriber, and a //! required list cannot be left however the form is posted. use crate::harness::TestHarness; use makenotwork::db::{ ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists, }; /// The signed preferences URL for one subscription. `lists_notifications` posts /// to it too, since the notification lists are reached through the same surface. pub(crate) fn prefs_url(subscription: makenotwork::db::ListSubscriptionId) -> String { makenotwork::email::generate_subscription_unsubscribe_url( "", *subscription.as_uuid(), "test-signing-secret-for-integration-tests", ) } /// Subscribe an address to the platform marketing list and return the row. async fn marketing_subscription( h: &TestHarness, addr: &str, ) -> makenotwork::db::ListSubscriptionId { let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing) .await .unwrap() .unwrap(); lists::subscribe( &h.db, list, &lists::Subscriber::Email(addr.to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap() } /// GET renders the page and changes nothing. A mail client or link scanner /// prefetching the URL must not unsubscribe anyone. #[tokio::test] async fn the_preferences_page_does_not_mutate_on_get() { let mut h = TestHarness::new().await; let sub = marketing_subscription(&h, "prefs@example.com").await; let resp = h.client.get(&prefs_url(sub)).await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Email preferences")); let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") .bind(sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "confirmed", "a GET unsubscribed somebody"); } /// RFC 8058: a POST to the same URL unsubscribes that one list with no /// confirmation step, and a retry still reports success. #[tokio::test] async fn one_click_post_unsubscribes_that_list_and_retries_cleanly() { let mut h = TestHarness::new().await; let sub = marketing_subscription(&h, "oneclick2@example.com").await; let url = prefs_url(sub); let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; assert_eq!(first.status, 200); let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") .bind(sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "unsubscribed"); let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; assert_eq!(second.status, 200, "a retried one-click must not fail"); } /// The page lists every list the subscriber is on, not only the one whose mail /// brought them there. Making somebody hunt for the rest is how "unsubscribe" /// becomes "mark as spam". #[tokio::test] async fn the_page_shows_every_list_the_subscriber_is_on() { let mut h = TestHarness::new().await; let marketing = marketing_subscription(&h, "many@example.com").await; // A second list for the same address. sqlx::query( "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Product announcements', false)", ) .execute(&h.db) .await .unwrap(); let announce = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce) .await .unwrap() .unwrap(); lists::subscribe( &h.db, announce, &lists::Subscriber::Email("many@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::LandingForm, ConsentEvent::OptIn, None, ) .await .unwrap(); let resp = h.client.get(&prefs_url(marketing)).await; assert!(resp.text.contains("Makenotwork updates")); assert!( resp.text.contains("Product announcements"), "the page showed only the originating list" ); } /// Required lists appear but carry no toggle. There is no opting out of a /// receipt, and "unsubscribe from everything" means everything on offer. #[tokio::test] async fn required_lists_are_shown_but_cannot_be_left() { let mut h = TestHarness::new().await; let marketing = marketing_subscription(&h, "receipts@example.com").await; sqlx::query( "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Receipts', true)", ) .execute(&h.db) .await .unwrap(); let receipts = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce) .await .unwrap() .unwrap(); let receipt_sub = lists::subscribe( &h.db, receipts, &lists::Subscriber::Email("receipts@example.com".to_string()), SubscriptionState::Confirmed, SubscriptionSource::Admin, ConsentEvent::OptIn, None, ) .await .unwrap(); let page = h.client.get(&prefs_url(marketing)).await; assert!( page.text.contains("Always sent"), "required list had a toggle" ); // Unsubscribe-from-all leaves it alone. let url = prefs_url(marketing); let token = url.split("sub=").nth(1).unwrap(); let (sub, sig) = token.split_once("&sig=").unwrap(); h.client .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}")) .await; let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") .bind(receipt_sub) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "confirmed", "a required list was unsubscribed"); let marketing_state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") .bind(marketing) .fetch_one(&h.db) .await .unwrap(); assert_eq!(marketing_state, "unsubscribed"); } /// A valid token authorises one subscriber, not any subscription. Retargeting /// it at somebody else's row is refused. #[tokio::test] async fn a_token_cannot_be_retargeted_at_another_subscriber() { let mut h = TestHarness::new().await; let mine = marketing_subscription(&h, "mine@example.com").await; let theirs = marketing_subscription(&h, "theirs@example.com").await; let url = prefs_url(mine); let token = url.split("sub=").nth(1).unwrap(); let (sub, sig) = token.split_once("&sig=").unwrap(); let resp = h .client .post_form( "/unsubscribe/list", &format!("sub={sub}&sig={sig}&target={theirs}&action=unsubscribe"), ) .await; assert_eq!(resp.status, 400, "a token was retargeted"); let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1") .bind(theirs) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "confirmed", "somebody else was unsubscribed"); } /// A forged signature does nothing. #[tokio::test] async fn the_preferences_page_rejects_a_bad_signature() { let mut h = TestHarness::new().await; let sub = marketing_subscription(&h, "forged@example.com").await; let resp = h .client .get(&format!("/unsubscribe?sub={sub}&sig=deadbeef")) .await; assert!( !resp.text.contains("Email preferences"), "a forged signature opened the page" ); }