Skip to main content

max / makenotwork

test: embed widget workflow coverage (18 tests) Closes the embeddable-widgets coverage gap in R26-115-122. The embed routes had zero workflow tests before; this file exercises all five. Routes covered: - GET /embed/i/{id}/button — buy/get button - GET /embed/i/{id}/card — product card (vertical + horizontal) - GET /embed/i/{id}/player — audio preview player - GET /embed/p/{slug}/card — project card - GET /embed/u/{user}/tip — tip button Three things asserted on every endpoint: - Happy path renders with expected title/price/button text - Iframe-friendly headers are set (X-Frame-Options: ALLOWALL, CSP frame-ancestors *, Cache-Control: public, max-age=300). These are the third-party-embedding contract. - Privacy gates return 404 — drafts, suspended creators, deactivated creators, tips-disabled users, audio-less items on the audio player, invalid usernames. XSS escape path tested on item titles since the embed module HTML-formats strings into static templates without an escaping templating engine (explicit html_escape calls). Special cases: - Free items render "Free" + "Get" button (not "Buy") - PWYW items render "$X.XX+" with trailing plus sign - layout=horizontal switches the card to flex-direction: row - Audio player requires audio_s3_key — items without it 404 - Tip button gates on tips_enabled (default false), so the default state for a creator is 404 until they opt in R26-115-122 status: - 7/8 features now have dedicated workflows - audio/video streaming remains ungated (only feature left)
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 15:39 UTC
Commit: e5be484f36dcf29e34c56408d2be577847bef895
Parent: a8eaf83
2 files changed, +383 insertions, -0 deletions
@@ -0,0 +1,382 @@
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 + }
@@ -1,5 +1,6 @@
1 1 mod auth;
2 2 mod discover;
3 + mod embeds;
3 4 mod creator;
4 5 mod purchase;
5 6 mod content;