Skip to main content

max / makenotwork

14.7 KB · 434 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 // The two layouts are one description in two documents: the row is the same
228 // row and the body class is what says how this frame lays it out. Vertical
229 // is the default, so the horizontal marker must be absent.
230 assert!(
231 resp.text.contains(r#"class="embed-card""#),
232 "Default layout should be the plain card: {}",
233 resp.text
234 );
235 assert_embed_headers(&resp, "item card");
236 }
237
238 #[tokio::test]
239 async fn item_card_horizontal_layout_query_param() {
240 let mut h = TestHarness::new().await;
241 let (item_id, _) = make_public_item(&mut h, "cardh", "Horiz Title", "digital", 1000).await;
242
243 let resp = h
244 .client
245 .get(&format!("/embed/i/{item_id}/card?layout=horizontal"))
246 .await;
247 assert_eq!(resp.status, 200, "{}", resp.text);
248 assert!(
249 resp.text.contains("embed-card-horizontal"),
250 "layout=horizontal should mark the document: {}",
251 resp.text
252 );
253 }
254
255 #[tokio::test]
256 async fn item_card_escapes_title_for_xss() {
257 let mut h = TestHarness::new().await;
258 let setup = h.create_creator_with_item("xss1", "digital", 1000).await;
259 // Inject a script tag in the title, the embed must HTML-escape it.
260 sqlx::query("UPDATE items SET title = '<script>alert(1)</script>', is_public = true WHERE id = $1::uuid")
261 .bind(&setup.item_id)
262 .execute(&h.db)
263 .await
264 .unwrap();
265 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
266 .bind(&setup.project_id)
267 .execute(&h.db)
268 .await
269 .unwrap();
270
271 let resp = h
272 .client
273 .get(&format!("/embed/i/{}/card", setup.item_id))
274 .await;
275 assert_eq!(resp.status, 200, "{}", resp.text);
276 assert!(
277 !resp.text.contains("<script>alert(1)</script>"),
278 "Raw script tag must NOT appear in embed output"
279 );
280 // The described renderer escapes to named entities, which are as safe as the
281 // numeric references Askama wrote before the embeds were described.
282 assert!(
283 resp.text.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
284 "Escaped script tag should appear in embed output: {}",
285 resp.text
286 );
287 }
288
289 // ───────────────────────── /embed/i/{id}/player ──────────────────────────
290
291 #[tokio::test]
292 async fn item_player_renders_for_item_with_audio() {
293 let mut h = TestHarness::new().await;
294 let (item_id, _) = make_public_item(&mut h, "audplay", "Audio Title", "audio", 1000).await;
295 // Player gates on `audio_s3_key.is_some()`, so we plant a fake key.
296 sqlx::query("UPDATE items SET audio_s3_key = 'fake/key.mp3' WHERE id = $1::uuid")
297 .bind(&item_id)
298 .execute(&h.db)
299 .await
300 .unwrap();
301
302 let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await;
303 assert_eq!(resp.status, 200, "{} {}", resp.status, resp.text);
304 assert!(resp.text.contains("Audio Title"));
305 // Player has a play button + progress bar.
306 assert!(resp.text.contains("play-btn"));
307 assert!(resp.text.contains("progress-bar"));
308 assert_embed_headers(&resp, "item player");
309 }
310
311 #[tokio::test]
312 async fn item_player_returns_404_for_item_without_audio() {
313 let mut h = TestHarness::new().await;
314 // No audio_s3_key set, the player must 404.
315 let (item_id, _) = make_public_item(&mut h, "noaudio", "No Audio", "digital", 1000).await;
316
317 let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await;
318 assert_eq!(
319 resp.status.as_u16(),
320 404,
321 "Audio player must 404 when item has no audio_s3_key"
322 );
323 }
324
325 // ───────────────────────── /embed/p/{slug}/card ──────────────────────────
326
327 #[tokio::test]
328 async fn project_card_renders_with_iframe_headers() {
329 let mut h = TestHarness::new().await;
330 let setup = h
331 .create_creator_with_item("projcard", "digital", 1000)
332 .await;
333 sqlx::query(
334 "UPDATE projects SET title = 'Project Card Title', is_public = true WHERE id = $1::uuid",
335 )
336 .bind(&setup.project_id)
337 .execute(&h.db)
338 .await
339 .unwrap();
340
341 let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await;
342 assert_eq!(resp.status, 200, "{} {}", resp.status, resp.text);
343 assert!(resp.text.contains("Project Card Title"));
344 assert_embed_headers(&resp, "project card");
345 }
346
347 #[tokio::test]
348 async fn project_card_returns_404_for_private_project() {
349 let mut h = TestHarness::new().await;
350 let setup = h
351 .create_creator_with_item("privproj", "digital", 1000)
352 .await;
353 sqlx::query("UPDATE projects SET is_public = false WHERE id = $1::uuid")
354 .bind(&setup.project_id)
355 .execute(&h.db)
356 .await
357 .unwrap();
358
359 let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await;
360 assert_eq!(resp.status.as_u16(), 404, "Private project embed must 404");
361 }
362
363 #[tokio::test]
364 async fn project_card_returns_404_for_nonexistent_slug() {
365 let mut h = TestHarness::new().await;
366 let resp = h.client.get("/embed/p/no-such-project/card").await;
367 assert_eq!(resp.status.as_u16(), 404);
368 }
369
370 // ───────────────────────── /embed/u/{user}/tip ───────────────────────────
371
372 #[tokio::test]
373 async fn tip_button_renders_when_tips_enabled() {
374 let mut h = TestHarness::new().await;
375 let user_id = h.create_creator("tipper").await;
376 // `tips_enabled` defaults to false, tip embed gates on this.
377 sqlx::query("UPDATE users SET tips_enabled = true WHERE id = $1")
378 .bind(user_id)
379 .execute(&h.db)
380 .await
381 .unwrap();
382
383 let resp = h.client.get("/embed/u/tipper/tip").await;
384 assert_eq!(resp.status, 200, "{} {}", resp.status, resp.text);
385 assert!(
386 resp.text.contains("tipper"),
387 "Tip embed should include the creator's username"
388 );
389 assert_embed_headers(&resp, "tip button");
390 }
391
392 #[tokio::test]
393 async fn tip_button_returns_404_when_tips_disabled() {
394 let mut h = TestHarness::new().await;
395 // Default for a newly-created creator is `tips_enabled = false`.
396 h.create_creator("notipper").await;
397
398 let resp = h.client.get("/embed/u/notipper/tip").await;
399 assert_eq!(
400 resp.status.as_u16(),
401 404,
402 "Tip embed must 404 when the creator hasn't opted in"
403 );
404 }
405
406 #[tokio::test]
407 async fn tip_button_returns_404_for_suspended_creator() {
408 let mut h = TestHarness::new().await;
409 let user_id = h.create_creator("suspendtip").await;
410 sqlx::query(
411 "UPDATE users SET tips_enabled = true, suspended_at = NOW(), \
412 suspension_reason = 'test' WHERE id = $1",
413 )
414 .bind(user_id)
415 .execute(&h.db)
416 .await
417 .unwrap();
418
419 let resp = h.client.get("/embed/u/suspendtip/tip").await;
420 assert_eq!(
421 resp.status.as_u16(),
422 404,
423 "Suspended creator's tip embed must 404 even with tips_enabled"
424 );
425 }
426
427 #[tokio::test]
428 async fn tip_button_returns_404_for_invalid_username() {
429 let mut h = TestHarness::new().await;
430 // `Username::new` validation rejects this shape, handler returns 404.
431 let resp = h.client.get("/embed/u/...not_a_username.../tip").await;
432 assert_eq!(resp.status.as_u16(), 404);
433 }
434