Skip to main content

max / makenotwork

5.0 KB · 166 lines History Blame Raw
1 //! Postmark webhook tests: bounce handling, spam complaints, token auth, suppression list.
2
3 use crate::harness::TestHarness;
4
5 const TOKEN: &str = "test-postmark-token";
6 const BROADCAST_TOKEN: &str = "test-broadcast-token";
7
8 /// Helper: POST a Postmark webhook payload with the given auth token.
9 async fn post_webhook(h: &mut TestHarness, token: Option<&str>, body: &str) -> u16 {
10 let mut headers = vec![("Content-Type", "application/json")];
11 let auth;
12 if let Some(t) = token {
13 auth = format!("Bearer {t}");
14 headers.push(("Authorization", &auth));
15 }
16 let resp = h
17 .client
18 .request_with_headers("POST", "/postmark/webhook", Some(body), &headers)
19 .await;
20 resp.status.as_u16()
21 }
22
23 // ── Auth ──
24
25 #[tokio::test]
26 async fn webhook_missing_token_returns_401() {
27 let mut h = TestHarness::with_postmark().await;
28
29 let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#;
30 let status = post_webhook(&mut h, None, body).await;
31 assert_eq!(status, 401);
32 }
33
34 #[tokio::test]
35 async fn webhook_invalid_token_returns_401() {
36 let mut h = TestHarness::with_postmark().await;
37
38 let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#;
39 let status = post_webhook(&mut h, Some("wrong-token"), body).await;
40 assert_eq!(status, 401);
41 }
42
43 #[tokio::test]
44 async fn webhook_no_config_token_returns_401() {
45 // Default harness has no postmark_webhook_token set
46 let mut h = TestHarness::new().await;
47
48 let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#;
49 let status = post_webhook(&mut h, Some(TOKEN), body).await;
50 assert_eq!(status, 401);
51 }
52
53 #[tokio::test]
54 async fn broadcast_token_accepted_by_webhook() {
55 let mut h = TestHarness::with_postmark().await;
56
57 let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#;
58 let status = post_webhook(&mut h, Some(BROADCAST_TOKEN), body).await;
59 assert_eq!(status, 200);
60 }
61
62 // ── Hard Bounce ──
63
64 #[tokio::test]
65 async fn hard_bounce_adds_suppression() {
66 let mut h = TestHarness::with_postmark().await;
67
68 let body = r#"{"RecordType":"Bounce","Email":"hardbounce@example.com","Type":"HardBounce"}"#;
69 let status = post_webhook(&mut h, Some(TOKEN), body).await;
70 assert_eq!(status, 200);
71
72 // Verify email is suppressed
73 let suppressed: bool = sqlx::query_scalar(
74 "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'hardbounce@example.com')",
75 )
76 .fetch_one(&h.db)
77 .await
78 .unwrap();
79 assert!(
80 suppressed,
81 "Hard-bounced email should be on suppression list"
82 );
83 }
84
85 // ── Soft Bounce ──
86
87 #[tokio::test]
88 async fn soft_bounce_does_not_suppress() {
89 let mut h = TestHarness::with_postmark().await;
90
91 let body = r#"{"RecordType":"Bounce","Email":"softbounce@example.com","Type":"SoftBounce"}"#;
92 let status = post_webhook(&mut h, Some(TOKEN), body).await;
93 assert_eq!(status, 200);
94
95 let suppressed: bool = sqlx::query_scalar(
96 "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'softbounce@example.com')",
97 )
98 .fetch_one(&h.db)
99 .await
100 .unwrap();
101 assert!(!suppressed, "Soft bounce should NOT be suppressed");
102 }
103
104 // ── Spam Complaint ──
105
106 #[tokio::test]
107 async fn spam_complaint_adds_suppression() {
108 let mut h = TestHarness::with_postmark().await;
109
110 let body = r#"{"RecordType":"SpamComplaint","Email":"spammer@example.com"}"#;
111 let status = post_webhook(&mut h, Some(TOKEN), body).await;
112 assert_eq!(status, 200);
113
114 let reason: String = sqlx::query_scalar(
115 "SELECT reason FROM email_suppressions WHERE email = 'spammer@example.com'",
116 )
117 .fetch_one(&h.db)
118 .await
119 .unwrap();
120 assert_eq!(reason, "SpamComplaint");
121 }
122
123 // ── Unhandled Types ──
124
125 #[tokio::test]
126 async fn unhandled_record_type_returns_200() {
127 let mut h = TestHarness::with_postmark().await;
128
129 let body = r#"{"RecordType":"Delivery","Email":"delivered@example.com"}"#;
130 let status = post_webhook(&mut h, Some(TOKEN), body).await;
131 assert_eq!(status, 200);
132
133 // Should not create a suppression entry
134 let suppressed: bool = sqlx::query_scalar(
135 "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'delivered@example.com')",
136 )
137 .fetch_one(&h.db)
138 .await
139 .unwrap();
140 assert!(!suppressed);
141 }
142
143 // ── Idempotency ──
144
145 #[tokio::test]
146 async fn duplicate_suppression_is_idempotent() {
147 let mut h = TestHarness::with_postmark().await;
148
149 let body = r#"{"RecordType":"Bounce","Email":"dupe@example.com","Type":"HardBounce"}"#;
150
151 // Send twice
152 let s1 = post_webhook(&mut h, Some(TOKEN), body).await;
153 let s2 = post_webhook(&mut h, Some(TOKEN), body).await;
154 assert_eq!(s1, 200);
155 assert_eq!(s2, 200);
156
157 // Should still have exactly one entry
158 let count: i64 = sqlx::query_scalar(
159 "SELECT COUNT(*) FROM email_suppressions WHERE email = 'dupe@example.com'",
160 )
161 .fetch_one(&h.db)
162 .await
163 .unwrap();
164 assert_eq!(count, 1, "Duplicate suppression should be idempotent");
165 }
166