Skip to main content

max / makenotwork

9.5 KB · 296 lines History Blame Raw
1 //! The unified subscription model (step 2 of wiki `mnw-mailing-lists`).
2 //!
3 //! Nothing sends through these tables yet, so what is worth pinning is the
4 //! shape: the constraints that make bad states unrepresentable, and the
5 //! append-only guarantee the consent log rests on. Those are the parts a later
6 //! step will lean on without re-checking.
7 //!
8 //! The later steps live beside this one: `lists_resolver` for the audience,
9 //! `lists_preferences` for the unsubscribe surface, `lists_notifications` for
10 //! the per-account and per-repo notification lists.
11
12 use crate::harness::TestHarness;
13 use makenotwork::db::{
14 ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
15 };
16
17 /// The backfill creates the platform marketing list unconditionally, so the
18 /// landing form has somewhere to point once step 3 wires it up.
19 #[tokio::test]
20 async fn migration_creates_the_platform_marketing_list() {
21 let h = TestHarness::new().await;
22
23 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
24 .await
25 .expect("query")
26 .expect("platform marketing list should exist after the backfill");
27
28 let count = lists::count_in_state(&h.db, list, SubscriptionState::Imported)
29 .await
30 .expect("count");
31 assert_eq!(count, 0, "a fresh database has nothing to import");
32 }
33
34 /// A subscriber is an account or a bare address, never both and never neither.
35 /// The old mailing_list_subscribers allowed both at once, which left "which is
36 /// authoritative" to whoever read the row next.
37 #[tokio::test]
38 async fn a_subscription_cannot_have_both_identities_or_neither() {
39 let h = TestHarness::new().await;
40 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
41 .await
42 .unwrap()
43 .unwrap();
44
45 let both = sqlx::query(
46 "INSERT INTO list_subscriptions (list_id, user_id, email, state, source) \
47 VALUES ($1, gen_random_uuid(), 'both@example.com', 'confirmed', 'api')",
48 )
49 .bind(list)
50 .execute(&h.db)
51 .await;
52 assert!(both.is_err(), "a row with both identities was accepted");
53
54 let neither = sqlx::query(
55 "INSERT INTO list_subscriptions (list_id, state, source) VALUES ($1, 'confirmed', 'api')",
56 )
57 .bind(list)
58 .execute(&h.db)
59 .await;
60 assert!(neither.is_err(), "a row with no identity was accepted");
61 }
62
63 /// scope_id is NULL exactly for platform lists. A project list without a
64 /// project, or a platform list that acquired one, is not a representable state.
65 #[tokio::test]
66 async fn list_scope_and_scope_id_must_agree() {
67 let h = TestHarness::new().await;
68
69 let platform_with_id = sqlx::query(
70 "INSERT INTO lists (scope, scope_id, kind, title) \
71 VALUES ('platform', gen_random_uuid(), 'announce', 'bad')",
72 )
73 .execute(&h.db)
74 .await;
75 assert!(platform_with_id.is_err());
76
77 let project_without_id =
78 sqlx::query("INSERT INTO lists (scope, kind, title) VALUES ('project', 'content', 'bad')")
79 .execute(&h.db)
80 .await;
81 assert!(project_without_id.is_err());
82 }
83
84 /// Subscribing writes the subscription and its consent event together. A
85 /// subscription with no recorded reason is the exact state this model exists to
86 /// eliminate.
87 #[tokio::test]
88 async fn subscribing_records_the_consent_event_with_it() {
89 let h = TestHarness::new().await;
90 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
91 .await
92 .unwrap()
93 .unwrap();
94
95 let sub = lists::subscribe(
96 &h.db,
97 list,
98 &lists::Subscriber::Email("consent@example.com".to_string()),
99 SubscriptionState::Confirmed,
100 SubscriptionSource::LandingForm,
101 ConsentEvent::OptIn,
102 Some("Get notified when something ships."),
103 )
104 .await
105 .expect("subscribe");
106
107 let (event, evidence): (String, Option<String>) =
108 sqlx::query_as("SELECT event, evidence FROM consent_events WHERE subscription_id = $1")
109 .bind(sub)
110 .fetch_one(&h.db)
111 .await
112 .expect("consent event");
113 assert_eq!(event, "opt_in");
114 assert_eq!(
115 evidence.as_deref(),
116 Some("Get notified when something ships."),
117 "the copy shown at opt-in is what makes the consent evidenceable"
118 );
119 }
120
121 /// Unsubscribing appends rather than edits, and is idempotent because one-click
122 /// POSTs get retried.
123 #[tokio::test]
124 async fn unsubscribing_appends_an_event_and_is_idempotent() {
125 let h = TestHarness::new().await;
126 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
127 .await
128 .unwrap()
129 .unwrap();
130
131 let sub = lists::subscribe(
132 &h.db,
133 list,
134 &lists::Subscriber::Email("leaving@example.com".to_string()),
135 SubscriptionState::Confirmed,
136 SubscriptionSource::LandingForm,
137 ConsentEvent::OptIn,
138 None,
139 )
140 .await
141 .unwrap();
142
143 assert!(
144 lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
145 .await
146 .unwrap()
147 );
148 assert!(
149 !lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
150 .await
151 .unwrap(),
152 "a retried unsubscribe must report no change rather than erroring"
153 );
154
155 // The opt_in survives the opt_out: the record of what was agreed to is the
156 // point of the table.
157 let events: Vec<String> = sqlx::query_scalar(
158 "SELECT event FROM consent_events WHERE subscription_id = $1 ORDER BY at",
159 )
160 .bind(sub)
161 .fetch_all(&h.db)
162 .await
163 .unwrap();
164 assert_eq!(events, vec!["opt_in".to_string(), "opt_out".to_string()]);
165 }
166
167 /// Re-subscribing moves the row back and appends a fresh event, rather than
168 /// rewriting the history that says they once left.
169 #[tokio::test]
170 async fn resubscribing_restores_the_row_and_keeps_the_history() {
171 let h = TestHarness::new().await;
172 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
173 .await
174 .unwrap()
175 .unwrap();
176 let subscriber = lists::Subscriber::Email("returning@example.com".to_string());
177
178 let sub = lists::subscribe(
179 &h.db,
180 list,
181 &subscriber,
182 SubscriptionState::Confirmed,
183 SubscriptionSource::LandingForm,
184 ConsentEvent::OptIn,
185 None,
186 )
187 .await
188 .unwrap();
189 lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
190 .await
191 .unwrap();
192
193 let again = lists::subscribe(
194 &h.db,
195 list,
196 &subscriber,
197 SubscriptionState::Confirmed,
198 SubscriptionSource::LandingForm,
199 ConsentEvent::OptIn,
200 None,
201 )
202 .await
203 .expect("re-subscribe should update rather than conflict");
204 assert_eq!(again, sub, "re-subscribing should reuse the row");
205
206 assert_eq!(
207 lists::count_in_state(&h.db, list, SubscriptionState::Confirmed)
208 .await
209 .unwrap(),
210 1
211 );
212 let events: i64 =
213 sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1")
214 .bind(sub)
215 .fetch_one(&h.db)
216 .await
217 .unwrap();
218 assert_eq!(events, 3, "opt_in, opt_out, opt_in");
219 }
220
221 /// The append-only guarantee is enforced by the database, not by convention.
222 /// Every consent claim rests on the log not having been edited after the fact.
223 #[tokio::test]
224 async fn consent_events_reject_updates() {
225 let h = TestHarness::new().await;
226 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
227 .await
228 .unwrap()
229 .unwrap();
230 let sub = lists::subscribe(
231 &h.db,
232 list,
233 &lists::Subscriber::Email("immutable@example.com".to_string()),
234 SubscriptionState::Confirmed,
235 SubscriptionSource::LandingForm,
236 ConsentEvent::OptIn,
237 Some("original copy"),
238 )
239 .await
240 .unwrap();
241
242 let rewrite =
243 sqlx::query("UPDATE consent_events SET evidence = 'rewritten' WHERE subscription_id = $1")
244 .bind(sub)
245 .execute(&h.db)
246 .await;
247 assert!(rewrite.is_err(), "consent history was editable");
248
249 let evidence: String =
250 sqlx::query_scalar("SELECT evidence FROM consent_events WHERE subscription_id = $1")
251 .bind(sub)
252 .fetch_one(&h.db)
253 .await
254 .unwrap();
255 assert_eq!(evidence, "original copy");
256 }
257
258 /// Erasure has to be able to remove the person, so DELETE cascades even though
259 /// UPDATE is blocked. This is the one way consent rows legitimately go away.
260 #[tokio::test]
261 async fn erasing_a_subscription_takes_its_consent_history_with_it() {
262 let h = TestHarness::new().await;
263 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
264 .await
265 .unwrap()
266 .unwrap();
267 let sub = lists::subscribe(
268 &h.db,
269 list,
270 &lists::Subscriber::Email("erase@example.com".to_string()),
271 SubscriptionState::Confirmed,
272 SubscriptionSource::LandingForm,
273 ConsentEvent::OptIn,
274 None,
275 )
276 .await
277 .unwrap();
278
279 sqlx::query("DELETE FROM list_subscriptions WHERE id = $1")
280 .bind(sub)
281 .execute(&h.db)
282 .await
283 .expect("erasure must be possible");
284
285 let left: i64 =
286 sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1")
287 .bind(sub)
288 .fetch_one(&h.db)
289 .await
290 .unwrap();
291 assert_eq!(
292 left, 0,
293 "consent rows outlived the subscription they described"
294 );
295 }
296