Skip to main content

max / makenotwork

5.0 KB · 163 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!(suppressed, "Hard-bounced email should be on suppression list");
80 }
81
82 // ── Soft Bounce ──
83
84 #[tokio::test]
85 async fn soft_bounce_does_not_suppress() {
86 let mut h = TestHarness::with_postmark().await;
87
88 let body = r#"{"RecordType":"Bounce","Email":"softbounce@example.com","Type":"SoftBounce"}"#;
89 let status = post_webhook(&mut h, Some(TOKEN), body).await;
90 assert_eq!(status, 200);
91
92 let suppressed: bool = sqlx::query_scalar(
93 "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'softbounce@example.com')",
94 )
95 .fetch_one(&h.db)
96 .await
97 .unwrap();
98 assert!(!suppressed, "Soft bounce should NOT be suppressed");
99 }
100
101 // ── Spam Complaint ──
102
103 #[tokio::test]
104 async fn spam_complaint_adds_suppression() {
105 let mut h = TestHarness::with_postmark().await;
106
107 let body = r#"{"RecordType":"SpamComplaint","Email":"spammer@example.com"}"#;
108 let status = post_webhook(&mut h, Some(TOKEN), body).await;
109 assert_eq!(status, 200);
110
111 let reason: String = sqlx::query_scalar(
112 "SELECT reason FROM email_suppressions WHERE email = 'spammer@example.com'",
113 )
114 .fetch_one(&h.db)
115 .await
116 .unwrap();
117 assert_eq!(reason, "SpamComplaint");
118 }
119
120 // ── Unhandled Types ──
121
122 #[tokio::test]
123 async fn unhandled_record_type_returns_200() {
124 let mut h = TestHarness::with_postmark().await;
125
126 let body = r#"{"RecordType":"Delivery","Email":"delivered@example.com"}"#;
127 let status = post_webhook(&mut h, Some(TOKEN), body).await;
128 assert_eq!(status, 200);
129
130 // Should not create a suppression entry
131 let suppressed: bool = sqlx::query_scalar(
132 "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'delivered@example.com')",
133 )
134 .fetch_one(&h.db)
135 .await
136 .unwrap();
137 assert!(!suppressed);
138 }
139
140 // ── Idempotency ──
141
142 #[tokio::test]
143 async fn duplicate_suppression_is_idempotent() {
144 let mut h = TestHarness::with_postmark().await;
145
146 let body = r#"{"RecordType":"Bounce","Email":"dupe@example.com","Type":"HardBounce"}"#;
147
148 // Send twice
149 let s1 = post_webhook(&mut h, Some(TOKEN), body).await;
150 let s2 = post_webhook(&mut h, Some(TOKEN), body).await;
151 assert_eq!(s1, 200);
152 assert_eq!(s2, 200);
153
154 // Should still have exactly one entry
155 let count: i64 = sqlx::query_scalar(
156 "SELECT COUNT(*) FROM email_suppressions WHERE email = 'dupe@example.com'",
157 )
158 .fetch_one(&h.db)
159 .await
160 .unwrap();
161 assert_eq!(count, 1, "Duplicate suppression should be idempotent");
162 }
163