Skip to main content

max / makenotwork

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