Skip to main content

max / makenotwork

7.6 KB · 229 lines History Blame Raw
1 //! The unsubscribe surface (step 4 of wiki `mnw-mailing-lists`).
2 //!
3 //! The preferences page and the one-click POST are the only list controls a
4 //! subscriber ever sees, and both are reached by a signed URL rather than a
5 //! session. What is worth pinning is that the signature is the whole of the
6 //! authorization: a GET mutates nothing, a token names one subscriber, and a
7 //! required list cannot be left however the form is posted.
8
9 use crate::harness::TestHarness;
10 use makenotwork::db::{
11 ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
12 };
13
14 /// The signed preferences URL for one subscription. `lists_notifications` posts
15 /// to it too, since the notification lists are reached through the same surface.
16 pub(crate) fn prefs_url(subscription: makenotwork::db::ListSubscriptionId) -> String {
17 makenotwork::email::generate_subscription_unsubscribe_url(
18 "",
19 *subscription.as_uuid(),
20 "test-signing-secret-for-integration-tests",
21 )
22 }
23
24 /// Subscribe an address to the platform marketing list and return the row.
25 async fn marketing_subscription(
26 h: &TestHarness,
27 addr: &str,
28 ) -> makenotwork::db::ListSubscriptionId {
29 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
30 .await
31 .unwrap()
32 .unwrap();
33 lists::subscribe(
34 &h.db,
35 list,
36 &lists::Subscriber::Email(addr.to_string()),
37 SubscriptionState::Confirmed,
38 SubscriptionSource::LandingForm,
39 ConsentEvent::OptIn,
40 None,
41 )
42 .await
43 .unwrap()
44 }
45
46 /// GET renders the page and changes nothing. A mail client or link scanner
47 /// prefetching the URL must not unsubscribe anyone.
48 #[tokio::test]
49 async fn the_preferences_page_does_not_mutate_on_get() {
50 let mut h = TestHarness::new().await;
51 let sub = marketing_subscription(&h, "prefs@example.com").await;
52
53 let resp = h.client.get(&prefs_url(sub)).await;
54 assert_eq!(resp.status, 200);
55 assert!(resp.text.contains("Email preferences"));
56
57 let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
58 .bind(sub)
59 .fetch_one(&h.db)
60 .await
61 .unwrap();
62 assert_eq!(state, "confirmed", "a GET unsubscribed somebody");
63 }
64
65 /// RFC 8058: a POST to the same URL unsubscribes that one list with no
66 /// confirmation step, and a retry still reports success.
67 #[tokio::test]
68 async fn one_click_post_unsubscribes_that_list_and_retries_cleanly() {
69 let mut h = TestHarness::new().await;
70 let sub = marketing_subscription(&h, "oneclick2@example.com").await;
71 let url = prefs_url(sub);
72
73 let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
74 assert_eq!(first.status, 200);
75
76 let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
77 .bind(sub)
78 .fetch_one(&h.db)
79 .await
80 .unwrap();
81 assert_eq!(state, "unsubscribed");
82
83 let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
84 assert_eq!(second.status, 200, "a retried one-click must not fail");
85 }
86
87 /// The page lists every list the subscriber is on, not only the one whose mail
88 /// brought them there. Making somebody hunt for the rest is how "unsubscribe"
89 /// becomes "mark as spam".
90 #[tokio::test]
91 async fn the_page_shows_every_list_the_subscriber_is_on() {
92 let mut h = TestHarness::new().await;
93 let marketing = marketing_subscription(&h, "many@example.com").await;
94
95 // A second list for the same address.
96 sqlx::query(
97 "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Product announcements', false)",
98 )
99 .execute(&h.db)
100 .await
101 .unwrap();
102 let announce = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
103 .await
104 .unwrap()
105 .unwrap();
106 lists::subscribe(
107 &h.db,
108 announce,
109 &lists::Subscriber::Email("many@example.com".to_string()),
110 SubscriptionState::Confirmed,
111 SubscriptionSource::LandingForm,
112 ConsentEvent::OptIn,
113 None,
114 )
115 .await
116 .unwrap();
117
118 let resp = h.client.get(&prefs_url(marketing)).await;
119 assert!(resp.text.contains("Makenotwork updates"));
120 assert!(
121 resp.text.contains("Product announcements"),
122 "the page showed only the originating list"
123 );
124 }
125
126 /// Required lists appear but carry no toggle. There is no opting out of a
127 /// receipt, and "unsubscribe from everything" means everything on offer.
128 #[tokio::test]
129 async fn required_lists_are_shown_but_cannot_be_left() {
130 let mut h = TestHarness::new().await;
131 let marketing = marketing_subscription(&h, "receipts@example.com").await;
132
133 sqlx::query(
134 "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Receipts', true)",
135 )
136 .execute(&h.db)
137 .await
138 .unwrap();
139 let receipts = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
140 .await
141 .unwrap()
142 .unwrap();
143 let receipt_sub = lists::subscribe(
144 &h.db,
145 receipts,
146 &lists::Subscriber::Email("receipts@example.com".to_string()),
147 SubscriptionState::Confirmed,
148 SubscriptionSource::Admin,
149 ConsentEvent::OptIn,
150 None,
151 )
152 .await
153 .unwrap();
154
155 let page = h.client.get(&prefs_url(marketing)).await;
156 assert!(
157 page.text.contains("Always sent"),
158 "required list had a toggle"
159 );
160
161 // Unsubscribe-from-all leaves it alone.
162 let url = prefs_url(marketing);
163 let token = url.split("sub=").nth(1).unwrap();
164 let (sub, sig) = token.split_once("&sig=").unwrap();
165 h.client
166 .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}"))
167 .await;
168
169 let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
170 .bind(receipt_sub)
171 .fetch_one(&h.db)
172 .await
173 .unwrap();
174 assert_eq!(state, "confirmed", "a required list was unsubscribed");
175
176 let marketing_state: String =
177 sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
178 .bind(marketing)
179 .fetch_one(&h.db)
180 .await
181 .unwrap();
182 assert_eq!(marketing_state, "unsubscribed");
183 }
184
185 /// A valid token authorises one subscriber, not any subscription. Retargeting
186 /// it at somebody else's row is refused.
187 #[tokio::test]
188 async fn a_token_cannot_be_retargeted_at_another_subscriber() {
189 let mut h = TestHarness::new().await;
190 let mine = marketing_subscription(&h, "mine@example.com").await;
191 let theirs = marketing_subscription(&h, "theirs@example.com").await;
192
193 let url = prefs_url(mine);
194 let token = url.split("sub=").nth(1).unwrap();
195 let (sub, sig) = token.split_once("&sig=").unwrap();
196
197 let resp = h
198 .client
199 .post_form(
200 "/unsubscribe/list",
201 &format!("sub={sub}&sig={sig}&target={theirs}&action=unsubscribe"),
202 )
203 .await;
204 assert_eq!(resp.status, 400, "a token was retargeted");
205
206 let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
207 .bind(theirs)
208 .fetch_one(&h.db)
209 .await
210 .unwrap();
211 assert_eq!(state, "confirmed", "somebody else was unsubscribed");
212 }
213
214 /// A forged signature does nothing.
215 #[tokio::test]
216 async fn the_preferences_page_rejects_a_bad_signature() {
217 let mut h = TestHarness::new().await;
218 let sub = marketing_subscription(&h, "forged@example.com").await;
219
220 let resp = h
221 .client
222 .get(&format!("/unsubscribe?sub={sub}&sig=deadbeef"))
223 .await;
224 assert!(
225 !resp.text.contains("Email preferences"),
226 "a forged signature opened the page"
227 );
228 }
229