Skip to main content

max / makenotwork

7.2 KB · 213 lines History Blame Raw
1 //! Signup and notify mailing-list tests: storing an address, the unsubscribe
2 //! link, and resubscribing.
3 //!
4 //! Split out of `pages` because the subject is the mailing list's lifecycle
5 //! rather than whether a route renders. The form lives on a page; what these
6 //! assert is what happens to the address afterwards.
7
8 use crate::harness::TestHarness;
9
10 /// The form works with JS off.
11 ///
12 /// It had no action and no method, so a browser without JS submitted a GET to
13 /// `/` and the address was dropped silently. This is the only email capture on
14 /// the landing page, so a silent loss is a lost signup nobody can count.
15 #[tokio::test]
16 async fn notify_form_without_js_stores_the_address() {
17 let mut h = TestHarness::new().await;
18 h.client.fetch_csrf_token().await;
19
20 let resp = h
21 .client
22 .post_form("/notify", "email=nojs@example.com")
23 .await;
24 assert_eq!(
25 resp.status, 303,
26 "expected a redirect back to the landing page"
27 );
28 let location = resp
29 .headers
30 .get("location")
31 .and_then(|v| v.to_str().ok())
32 .unwrap_or_default();
33 assert!(
34 location.starts_with("/?notify=ok"),
35 "redirected to {location}"
36 );
37
38 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1")
39 .bind("nojs@example.com")
40 .fetch_one(&h.db)
41 .await
42 .expect("count signups");
43 assert_eq!(stored, 1, "the address never reached storage");
44 }
45
46 /// A rejected address comes back as a sentence on the page, not a 422. This is
47 /// the last thing on the landing page and an error code is a worse outcome
48 /// than being told the address looked wrong.
49 #[tokio::test]
50 async fn notify_form_rejects_a_bad_address_without_erroring() {
51 let mut h = TestHarness::new().await;
52 h.client.fetch_csrf_token().await;
53
54 let resp = h.client.post_form("/notify", "email=not-an-address").await;
55 assert_eq!(resp.status, 303);
56 assert!(
57 resp.headers
58 .get("location")
59 .and_then(|v| v.to_str().ok())
60 .unwrap_or_default()
61 .starts_with("/?notify=invalid")
62 );
63
64 let landing = h.client.get("/?notify=invalid").await;
65 assert!(
66 landing.text.contains("That address didn't look right."),
67 "the landing page said nothing about the rejection"
68 );
69 }
70
71 /// The success message renders for a no-JS visitor coming back off the
72 /// redirect, and not on a plain page load.
73 #[tokio::test]
74 async fn notify_status_renders_only_when_the_redirect_says_so() {
75 let mut h = TestHarness::new().await;
76
77 let plain = h.client.get("/").await;
78 assert!(
79 !plain.text.contains("You're on the list."),
80 "status shown on a plain load"
81 );
82
83 let after = h.client.get("/?notify=ok").await;
84 assert!(after.text.contains("You're on the list."));
85 }
86
87 // ── Landing signup unsubscribe ──
88 //
89 // Step 1 of wiki [[mnw-mailing-lists]]. The table had insert, admin read and
90 // count, and no way out. Withdrawal has to be as easy as consent, and consent
91 // is one form field.
92
93 fn signup_unsub_url(email: &str) -> String {
94 makenotwork::email::generate_signup_unsubscribe_url(
95 "",
96 email,
97 "test-signing-secret-for-integration-tests",
98 )
99 }
100
101 /// The signed link unsubscribes, and the address stops being mailable.
102 #[tokio::test]
103 async fn signup_unsubscribe_link_removes_the_address_from_the_mailable_list() {
104 let mut h = TestHarness::new().await;
105 h.client.fetch_csrf_token().await;
106 h.client
107 .post_form("/notify", "email=leaving@example.com")
108 .await;
109
110 let before: i64 = sqlx::query_scalar(
111 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
112 )
113 .bind("leaving@example.com")
114 .fetch_one(&h.db)
115 .await
116 .expect("count");
117 assert_eq!(before, 1, "signup did not land");
118
119 let resp = h.client.get(&signup_unsub_url("leaving@example.com")).await;
120 assert_eq!(resp.status, 200);
121
122 let after: i64 = sqlx::query_scalar(
123 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
124 )
125 .bind("leaving@example.com")
126 .fetch_one(&h.db)
127 .await
128 .expect("count");
129 assert_eq!(after, 0, "still mailable after unsubscribing");
130
131 // Marked, not deleted: a deleted address is one the next import re-adds.
132 let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1")
133 .bind("leaving@example.com")
134 .fetch_one(&h.db)
135 .await
136 .expect("count");
137 assert_eq!(retained, 1, "the opt-out record was thrown away");
138 }
139
140 /// RFC 8058: a POST to the same URL unsubscribes with no confirmation step,
141 /// which is what the List-Unsubscribe-Post header promises Gmail and Yahoo.
142 /// Retries must not fail, so the second POST answers the same as the first.
143 #[tokio::test]
144 async fn signup_unsubscribe_one_click_post_is_idempotent() {
145 let mut h = TestHarness::new().await;
146 h.client.fetch_csrf_token().await;
147 h.client
148 .post_form("/notify", "email=oneclick@example.com")
149 .await;
150
151 let url = signup_unsub_url("oneclick@example.com");
152 let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
153 assert_eq!(first.status, 200);
154 let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
155 assert_eq!(second.status, 200, "a retried one-click POST must not fail");
156 }
157
158 /// A tampered signature does nothing. The address is signed over, so a token
159 /// for one address cannot unsubscribe another.
160 #[tokio::test]
161 async fn signup_unsubscribe_rejects_a_forged_link() {
162 let mut h = TestHarness::new().await;
163 h.client.fetch_csrf_token().await;
164 h.client
165 .post_form("/notify", "email=victim@example.com")
166 .await;
167
168 // Take a valid token for one address and point it at another.
169 let forged = signup_unsub_url("attacker@example.com")
170 .replace("attacker%40example.com", "victim%40example.com");
171 let resp = h.client.get(&forged).await;
172 assert!(
173 resp.text.contains("Invalid Link"),
174 "a forged link was accepted"
175 );
176
177 let still_mailable: i64 = sqlx::query_scalar(
178 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
179 )
180 .bind("victim@example.com")
181 .fetch_one(&h.db)
182 .await
183 .expect("count");
184 assert_eq!(still_mailable, 1, "a forged link unsubscribed someone");
185 }
186
187 /// Signing up again after unsubscribing re-subscribes. The form is the only
188 /// interface, so refusing would leave someone unable to opt back in.
189 #[tokio::test]
190 async fn signing_up_again_after_unsubscribing_restores_the_subscription() {
191 let mut h = TestHarness::new().await;
192 h.client.fetch_csrf_token().await;
193 h.client
194 .post_form("/notify", "email=returning@example.com")
195 .await;
196 h.client
197 .get(&signup_unsub_url("returning@example.com"))
198 .await;
199
200 h.client
201 .post_form("/notify", "email=returning@example.com")
202 .await;
203
204 let mailable: i64 = sqlx::query_scalar(
205 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
206 )
207 .bind("returning@example.com")
208 .fetch_one(&h.db)
209 .await
210 .expect("count");
211 assert_eq!(mailable, 1, "could not opt back in");
212 }
213