Skip to main content

max / makenotwork

14.0 KB · 385 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 // Askama autoescapes to numeric character references (`&#60;`/`&#62;`),
236 // which are as safe as the old hand-roller's named entities (`&lt;`/`&gt;`).
237 assert!(
238 resp.text.contains("&#60;script&#62;alert(1)&#60;/script&#62;"),
239 "Escaped script tag should appear in embed output"
240 );
241 }
242
243 // ───────────────────────── /embed/i/{id}/player ──────────────────────────
244
245 #[tokio::test]
246 async fn item_player_renders_for_item_with_audio() {
247 let mut h = TestHarness::new().await;
248 let (item_id, _) =
249 make_public_item(&mut h, "audplay", "Audio Title", "audio", 1000).await;
250 // Player gates on `audio_s3_key.is_some()`, so we plant a fake key.
251 sqlx::query("UPDATE items SET audio_s3_key = 'fake/key.mp3' WHERE id = $1::uuid")
252 .bind(&item_id)
253 .execute(&h.db)
254 .await
255 .unwrap();
256
257 let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await;
258 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
259 assert!(resp.text.contains("Audio Title"));
260 // Player has a play button + progress bar.
261 assert!(resp.text.contains("play-btn"));
262 assert!(resp.text.contains("progress-bar"));
263 assert_embed_headers(&resp, "item player");
264 }
265
266 #[tokio::test]
267 async fn item_player_returns_404_for_item_without_audio() {
268 let mut h = TestHarness::new().await;
269 // No audio_s3_key set — the player must 404.
270 let (item_id, _) =
271 make_public_item(&mut h, "noaudio", "No Audio", "digital", 1000).await;
272
273 let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await;
274 assert_eq!(
275 resp.status.as_u16(),
276 404,
277 "Audio player must 404 when item has no audio_s3_key"
278 );
279 }
280
281 // ───────────────────────── /embed/p/{slug}/card ──────────────────────────
282
283 #[tokio::test]
284 async fn project_card_renders_with_iframe_headers() {
285 let mut h = TestHarness::new().await;
286 let setup = h.create_creator_with_item("projcard", "digital", 1000).await;
287 sqlx::query("UPDATE projects SET title = 'Project Card Title', is_public = true WHERE id = $1::uuid")
288 .bind(&setup.project_id)
289 .execute(&h.db)
290 .await
291 .unwrap();
292
293 let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await;
294 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
295 assert!(resp.text.contains("Project Card Title"));
296 assert_embed_headers(&resp, "project card");
297 }
298
299 #[tokio::test]
300 async fn project_card_returns_404_for_private_project() {
301 let mut h = TestHarness::new().await;
302 let setup = h.create_creator_with_item("privproj", "digital", 1000).await;
303 sqlx::query("UPDATE projects SET is_public = false WHERE id = $1::uuid")
304 .bind(&setup.project_id)
305 .execute(&h.db)
306 .await
307 .unwrap();
308
309 let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await;
310 assert_eq!(
311 resp.status.as_u16(),
312 404,
313 "Private project embed must 404"
314 );
315 }
316
317 #[tokio::test]
318 async fn project_card_returns_404_for_nonexistent_slug() {
319 let mut h = TestHarness::new().await;
320 let resp = h.client.get("/embed/p/no-such-project/card").await;
321 assert_eq!(resp.status.as_u16(), 404);
322 }
323
324 // ───────────────────────── /embed/u/{user}/tip ───────────────────────────
325
326 #[tokio::test]
327 async fn tip_button_renders_when_tips_enabled() {
328 let mut h = TestHarness::new().await;
329 let user_id = h.create_creator("tipper").await;
330 // `tips_enabled` defaults to false — tip embed gates on this.
331 sqlx::query("UPDATE users SET tips_enabled = true WHERE id = $1")
332 .bind(user_id)
333 .execute(&h.db)
334 .await
335 .unwrap();
336
337 let resp = h.client.get("/embed/u/tipper/tip").await;
338 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
339 assert!(resp.text.contains("tipper"), "Tip embed should include the creator's username");
340 assert_embed_headers(&resp, "tip button");
341 }
342
343 #[tokio::test]
344 async fn tip_button_returns_404_when_tips_disabled() {
345 let mut h = TestHarness::new().await;
346 // Default for a newly-created creator is `tips_enabled = false`.
347 h.create_creator("notipper").await;
348
349 let resp = h.client.get("/embed/u/notipper/tip").await;
350 assert_eq!(
351 resp.status.as_u16(),
352 404,
353 "Tip embed must 404 when the creator hasn't opted in"
354 );
355 }
356
357 #[tokio::test]
358 async fn tip_button_returns_404_for_suspended_creator() {
359 let mut h = TestHarness::new().await;
360 let user_id = h.create_creator("suspendtip").await;
361 sqlx::query(
362 "UPDATE users SET tips_enabled = true, suspended_at = NOW(), \
363 suspension_reason = 'test' WHERE id = $1",
364 )
365 .bind(user_id)
366 .execute(&h.db)
367 .await
368 .unwrap();
369
370 let resp = h.client.get("/embed/u/suspendtip/tip").await;
371 assert_eq!(
372 resp.status.as_u16(),
373 404,
374 "Suspended creator's tip embed must 404 even with tips_enabled"
375 );
376 }
377
378 #[tokio::test]
379 async fn tip_button_returns_404_for_invalid_username() {
380 let mut h = TestHarness::new().await;
381 // `Username::new` validation rejects this shape — handler returns 404.
382 let resp = h.client.get("/embed/u/...not_a_username.../tip").await;
383 assert_eq!(resp.status.as_u16(), 404);
384 }
385