Skip to main content

max / makenotwork

13.9 KB · 383 lines History Blame Raw
1 //! Embed widgets: item button/card/player, project card, user tip button.
2 //!
3 //! Covers all five embed routes under `/embed/`:
4 //! - GET /embed/i/{item_id}/button
5 //! - GET /embed/i/{item_id}/card
6 //! - GET /embed/i/{item_id}/player
7 //! - GET /embed/p/{project_slug}/card
8 //! - GET /embed/u/{username}/tip
9 //!
10 //! Tests three things on every endpoint:
11 //!
12 //! 1. **Happy path**: the embed renders with the expected title/price/btn
13 //! 2. **Iframe headers**: X-Frame-Options: ALLOWALL +
14 //! Content-Security-Policy: frame-ancestors * +
15 //! Cache-Control: public, max-age=300. These are the contract that
16 //! lets a third-party site iframe MNW content.
17 //! 3. **Privacy gates**: drafts (is_public=false), suspended creators,
18 //! deactivated creators, tips-disabled creators, and audio-less items
19 //! on the audio player all return 404.
20 //!
21 //! XSS escape paths are also tested on titles + display names because
22 //! every embed HTML-formats user-controlled strings into a static
23 //! template (no templating engine; explicit `html_escape` calls).
24
25 use crate::harness::TestHarness;
26
27 /// Assert the three iframe-friendly headers are set on an embed response.
28 fn assert_embed_headers(resp: &crate::harness::client::TestResponse, ctx: &str) {
29 assert_eq!(
30 resp.headers.get("x-frame-options").and_then(|v| v.to_str().ok()),
31 Some("ALLOWALL"),
32 "{ctx}: X-Frame-Options must be ALLOWALL so third-party sites can iframe"
33 );
34 let csp = resp
35 .headers
36 .get("content-security-policy")
37 .and_then(|v| v.to_str().ok())
38 .unwrap_or("");
39 assert!(
40 csp.contains("frame-ancestors *"),
41 "{ctx}: CSP must include `frame-ancestors *`, got {csp:?}"
42 );
43 let cc = resp
44 .headers
45 .get("cache-control")
46 .and_then(|v| v.to_str().ok())
47 .unwrap_or("");
48 assert!(
49 cc.contains("max-age=300") && cc.contains("public"),
50 "{ctx}: Cache-Control should be `public, max-age=300`, got {cc:?}"
51 );
52 }
53
54 /// Set up a public item with a known title + creator display name. Returns
55 /// (item_id, creator_username).
56 async fn make_public_item(
57 h: &mut TestHarness,
58 username: &str,
59 title: &str,
60 item_type: &str,
61 price_cents: i64,
62 ) -> (String, String) {
63 let setup = h.create_creator_with_item(username, item_type, price_cents).await;
64 sqlx::query(
65 "UPDATE items SET title = $1, is_public = true, listed = true, \
66 scan_status = 'clean' WHERE id = $2::uuid",
67 )
68 .bind(title)
69 .bind(&setup.item_id)
70 .execute(&h.db)
71 .await
72 .expect("publish item for embed");
73 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
74 .bind(&setup.project_id)
75 .execute(&h.db)
76 .await
77 .expect("publish project for embed");
78 (setup.item_id, username.to_string())
79 }
80
81 // ───────────────────────── /embed/i/{id}/button ─────────────────────────
82
83 #[tokio::test]
84 async fn item_button_renders_with_iframe_headers() {
85 let mut h = TestHarness::new().await;
86 let (item_id, _) = make_public_item(&mut h, "btn1", "Buyable Item", "digital", 1999).await;
87
88 let resp = h.client.get(&format!("/embed/i/{item_id}/button")).await;
89 assert!(resp.status.is_success(), "GET item button: {} {}", resp.status, resp.text);
90 assert!(resp.text.contains("Buyable Item"), "Embed should contain item title");
91 assert!(resp.text.contains("$19.99"), "Embed should render the price");
92 assert!(resp.text.contains("Buy"), "Embed should contain Buy button text");
93 assert_embed_headers(&resp, "item button");
94 }
95
96 #[tokio::test]
97 async fn item_button_returns_404_for_draft_item() {
98 let mut h = TestHarness::new().await;
99 let setup = h.create_creator_with_item("draftbtn", "digital", 1000).await;
100 // Explicitly hide — items default to is_public=true.
101 sqlx::query("UPDATE items SET is_public = false WHERE id = $1::uuid")
102 .bind(&setup.item_id)
103 .execute(&h.db)
104 .await
105 .unwrap();
106
107 let resp = h.client.get(&format!("/embed/i/{}/button", setup.item_id)).await;
108 assert_eq!(
109 resp.status.as_u16(),
110 404,
111 "Draft item embed must not leak; got {} {}",
112 resp.status,
113 resp.text
114 );
115 }
116
117 #[tokio::test]
118 async fn item_button_returns_404_for_suspended_creator() {
119 let mut h = TestHarness::new().await;
120 let (item_id, username) =
121 make_public_item(&mut h, "suspbtn", "Suspended Title", "digital", 500).await;
122
123 sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'test' WHERE username = $1")
124 .bind(&username)
125 .execute(&h.db)
126 .await
127 .unwrap();
128
129 let resp = h.client.get(&format!("/embed/i/{item_id}/button")).await;
130 assert_eq!(resp.status.as_u16(), 404, "Suspended creator's embed must 404");
131 }
132
133 #[tokio::test]
134 async fn item_button_returns_404_for_nonexistent_item() {
135 let mut h = TestHarness::new().await;
136 let bogus = "00000000-0000-0000-0000-000000000000";
137 let resp = h.client.get(&format!("/embed/i/{bogus}/button")).await;
138 assert_eq!(resp.status.as_u16(), 404);
139 }
140
141 #[tokio::test]
142 async fn item_button_free_item_renders_get_button() {
143 let mut h = TestHarness::new().await;
144 let (item_id, _) =
145 make_public_item(&mut h, "freebtn", "Free Title", "digital", 0).await;
146
147 let resp = h.client.get(&format!("/embed/i/{item_id}/button")).await;
148 assert!(resp.status.is_success());
149 assert!(resp.text.contains("Free"), "Free items show 'Free' price label");
150 assert!(resp.text.contains("Get"), "Free items show 'Get' button (not Buy)");
151 }
152
153 #[tokio::test]
154 async fn item_button_pwyw_renders_plus_suffix() {
155 let mut h = TestHarness::new().await;
156 let setup = h.create_creator_with_item("pwywbtn", "digital", 500).await;
157 sqlx::query(
158 "UPDATE items SET title = 'PWYW Title', is_public = true, pwyw_enabled = true, \
159 pwyw_min_cents = 500 WHERE id = $1::uuid",
160 )
161 .bind(&setup.item_id)
162 .execute(&h.db)
163 .await
164 .unwrap();
165 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
166 .bind(&setup.project_id)
167 .execute(&h.db)
168 .await
169 .unwrap();
170
171 let resp = h.client.get(&format!("/embed/i/{}/button", setup.item_id)).await;
172 assert!(resp.status.is_success());
173 assert!(
174 resp.text.contains("$5.00+"),
175 "PWYW pricing should append `+` to the price"
176 );
177 }
178
179 // ───────────────────────── /embed/i/{id}/card ────────────────────────────
180
181 #[tokio::test]
182 async fn item_card_renders_with_vertical_layout_by_default() {
183 let mut h = TestHarness::new().await;
184 let (item_id, _) = make_public_item(&mut h, "card1", "Card Title", "digital", 1500).await;
185
186 let resp = h.client.get(&format!("/embed/i/{item_id}/card")).await;
187 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
188 assert!(resp.text.contains("Card Title"));
189 assert!(resp.text.contains("$15.00"));
190 // Vertical layout sets flex-direction: column.
191 assert!(
192 resp.text.contains("column"),
193 "Default layout should be vertical (flex-direction: column)"
194 );
195 assert_embed_headers(&resp, "item card");
196 }
197
198 #[tokio::test]
199 async fn item_card_horizontal_layout_query_param() {
200 let mut h = TestHarness::new().await;
201 let (item_id, _) =
202 make_public_item(&mut h, "cardh", "Horiz Title", "digital", 1000).await;
203
204 let resp = h.client.get(&format!("/embed/i/{item_id}/card?layout=horizontal")).await;
205 assert!(resp.status.is_success());
206 // Horizontal layout switches to flex-direction: row.
207 assert!(
208 resp.text.contains("row"),
209 "layout=horizontal should set flex-direction: row"
210 );
211 }
212
213 #[tokio::test]
214 async fn item_card_escapes_title_for_xss() {
215 let mut h = TestHarness::new().await;
216 let setup = h.create_creator_with_item("xss1", "digital", 1000).await;
217 // Inject a script tag in the title — the embed must HTML-escape it.
218 sqlx::query("UPDATE items SET title = '<script>alert(1)</script>', is_public = true WHERE id = $1::uuid")
219 .bind(&setup.item_id)
220 .execute(&h.db)
221 .await
222 .unwrap();
223 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
224 .bind(&setup.project_id)
225 .execute(&h.db)
226 .await
227 .unwrap();
228
229 let resp = h.client.get(&format!("/embed/i/{}/card", setup.item_id)).await;
230 assert!(resp.status.is_success());
231 assert!(
232 !resp.text.contains("<script>alert(1)</script>"),
233 "Raw script tag must NOT appear in embed output"
234 );
235 assert!(
236 resp.text.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
237 "Escaped script tag should appear in embed output"
238 );
239 }
240
241 // ───────────────────────── /embed/i/{id}/player ──────────────────────────
242
243 #[tokio::test]
244 async fn item_player_renders_for_item_with_audio() {
245 let mut h = TestHarness::new().await;
246 let (item_id, _) =
247 make_public_item(&mut h, "audplay", "Audio Title", "audio", 1000).await;
248 // Player gates on `audio_s3_key.is_some()`, so we plant a fake key.
249 sqlx::query("UPDATE items SET audio_s3_key = 'fake/key.mp3' WHERE id = $1::uuid")
250 .bind(&item_id)
251 .execute(&h.db)
252 .await
253 .unwrap();
254
255 let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await;
256 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
257 assert!(resp.text.contains("Audio Title"));
258 // Player has a play button + progress bar.
259 assert!(resp.text.contains("play-btn"));
260 assert!(resp.text.contains("progress-bar"));
261 assert_embed_headers(&resp, "item player");
262 }
263
264 #[tokio::test]
265 async fn item_player_returns_404_for_item_without_audio() {
266 let mut h = TestHarness::new().await;
267 // No audio_s3_key set — the player must 404.
268 let (item_id, _) =
269 make_public_item(&mut h, "noaudio", "No Audio", "digital", 1000).await;
270
271 let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await;
272 assert_eq!(
273 resp.status.as_u16(),
274 404,
275 "Audio player must 404 when item has no audio_s3_key"
276 );
277 }
278
279 // ───────────────────────── /embed/p/{slug}/card ──────────────────────────
280
281 #[tokio::test]
282 async fn project_card_renders_with_iframe_headers() {
283 let mut h = TestHarness::new().await;
284 let setup = h.create_creator_with_item("projcard", "digital", 1000).await;
285 sqlx::query("UPDATE projects SET title = 'Project Card Title', is_public = true WHERE id = $1::uuid")
286 .bind(&setup.project_id)
287 .execute(&h.db)
288 .await
289 .unwrap();
290
291 let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await;
292 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
293 assert!(resp.text.contains("Project Card Title"));
294 assert_embed_headers(&resp, "project card");
295 }
296
297 #[tokio::test]
298 async fn project_card_returns_404_for_private_project() {
299 let mut h = TestHarness::new().await;
300 let setup = h.create_creator_with_item("privproj", "digital", 1000).await;
301 sqlx::query("UPDATE projects SET is_public = false WHERE id = $1::uuid")
302 .bind(&setup.project_id)
303 .execute(&h.db)
304 .await
305 .unwrap();
306
307 let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await;
308 assert_eq!(
309 resp.status.as_u16(),
310 404,
311 "Private project embed must 404"
312 );
313 }
314
315 #[tokio::test]
316 async fn project_card_returns_404_for_nonexistent_slug() {
317 let mut h = TestHarness::new().await;
318 let resp = h.client.get("/embed/p/no-such-project/card").await;
319 assert_eq!(resp.status.as_u16(), 404);
320 }
321
322 // ───────────────────────── /embed/u/{user}/tip ───────────────────────────
323
324 #[tokio::test]
325 async fn tip_button_renders_when_tips_enabled() {
326 let mut h = TestHarness::new().await;
327 let user_id = h.create_creator("tipper").await;
328 // `tips_enabled` defaults to false — tip embed gates on this.
329 sqlx::query("UPDATE users SET tips_enabled = true WHERE id = $1")
330 .bind(user_id)
331 .execute(&h.db)
332 .await
333 .unwrap();
334
335 let resp = h.client.get("/embed/u/tipper/tip").await;
336 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
337 assert!(resp.text.contains("tipper"), "Tip embed should include the creator's username");
338 assert_embed_headers(&resp, "tip button");
339 }
340
341 #[tokio::test]
342 async fn tip_button_returns_404_when_tips_disabled() {
343 let mut h = TestHarness::new().await;
344 // Default for a newly-created creator is `tips_enabled = false`.
345 h.create_creator("notipper").await;
346
347 let resp = h.client.get("/embed/u/notipper/tip").await;
348 assert_eq!(
349 resp.status.as_u16(),
350 404,
351 "Tip embed must 404 when the creator hasn't opted in"
352 );
353 }
354
355 #[tokio::test]
356 async fn tip_button_returns_404_for_suspended_creator() {
357 let mut h = TestHarness::new().await;
358 let user_id = h.create_creator("suspendtip").await;
359 sqlx::query(
360 "UPDATE users SET tips_enabled = true, suspended_at = NOW(), \
361 suspension_reason = 'test' WHERE id = $1",
362 )
363 .bind(user_id)
364 .execute(&h.db)
365 .await
366 .unwrap();
367
368 let resp = h.client.get("/embed/u/suspendtip/tip").await;
369 assert_eq!(
370 resp.status.as_u16(),
371 404,
372 "Suspended creator's tip embed must 404 even with tips_enabled"
373 );
374 }
375
376 #[tokio::test]
377 async fn tip_button_returns_404_for_invalid_username() {
378 let mut h = TestHarness::new().await;
379 // `Username::new` validation rejects this shape — handler returns 404.
380 let resp = h.client.get("/embed/u/...not_a_username.../tip").await;
381 assert_eq!(resp.status.as_u16(), 404);
382 }
383