Skip to main content

max / makenotwork

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