//! Embed widgets: item button/card/player, project card, user tip button. //! //! Covers all five embed routes under `/embed/`: //! - GET /embed/i/{item_id}/button //! - GET /embed/i/{item_id}/card //! - GET /embed/i/{item_id}/player //! - GET /embed/p/{project_slug}/card //! - GET /embed/u/{username}/tip //! //! Tests three things on every endpoint: //! //! 1. **Happy path**: the embed renders with the expected title/price/btn //! 2. **Iframe headers**: X-Frame-Options: ALLOWALL + //! Content-Security-Policy: frame-ancestors * + //! Cache-Control: public, max-age=300. These are the contract that //! lets a third-party site iframe MNW content. //! 3. **Privacy gates**: drafts (is_public=false), suspended creators, //! deactivated creators, tips-disabled creators, and audio-less items //! on the audio player all return 404. //! //! XSS escape paths are also tested on titles + display names because //! every embed HTML-formats user-controlled strings into a static //! template (no templating engine; explicit `html_escape` calls). use crate::harness::TestHarness; /// Assert the three iframe-friendly headers are set on an embed response. fn assert_embed_headers(resp: &crate::harness::client::TestResponse, ctx: &str) { assert_eq!( resp.headers.get("x-frame-options").and_then(|v| v.to_str().ok()), Some("ALLOWALL"), "{ctx}: X-Frame-Options must be ALLOWALL so third-party sites can iframe" ); let csp = resp .headers .get("content-security-policy") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( csp.contains("frame-ancestors *"), "{ctx}: CSP must include `frame-ancestors *`, got {csp:?}" ); let cc = resp .headers .get("cache-control") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( cc.contains("max-age=300") && cc.contains("public"), "{ctx}: Cache-Control should be `public, max-age=300`, got {cc:?}" ); } /// Set up a public item with a known title + creator display name. Returns /// (item_id, creator_username). async fn make_public_item( h: &mut TestHarness, username: &str, title: &str, item_type: &str, price_cents: i64, ) -> (String, String) { let setup = h.create_creator_with_item(username, item_type, price_cents).await; sqlx::query( "UPDATE items SET title = $1, is_public = true, listed = true, \ scan_status = 'clean' WHERE id = $2::uuid", ) .bind(title) .bind(&setup.item_id) .execute(&h.db) .await .expect("publish item for embed"); sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .expect("publish project for embed"); (setup.item_id, username.to_string()) } // ───────────────────────── /embed/i/{id}/button ───────────────────────── #[tokio::test] async fn item_button_renders_with_iframe_headers() { let mut h = TestHarness::new().await; let (item_id, _) = make_public_item(&mut h, "btn1", "Buyable Item", "digital", 1999).await; let resp = h.client.get(&format!("/embed/i/{item_id}/button")).await; assert!(resp.status.is_success(), "GET item button: {} {}", resp.status, resp.text); assert!(resp.text.contains("Buyable Item"), "Embed should contain item title"); assert!(resp.text.contains("$19.99"), "Embed should render the price"); assert!(resp.text.contains("Buy"), "Embed should contain Buy button text"); assert_embed_headers(&resp, "item button"); } #[tokio::test] async fn item_button_returns_404_for_draft_item() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("draftbtn", "digital", 1000).await; // Explicitly hide — items default to is_public=true. sqlx::query("UPDATE items SET is_public = false WHERE id = $1::uuid") .bind(&setup.item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/i/{}/button", setup.item_id)).await; assert_eq!( resp.status.as_u16(), 404, "Draft item embed must not leak; got {} {}", resp.status, resp.text ); } #[tokio::test] async fn item_button_returns_404_for_suspended_creator() { let mut h = TestHarness::new().await; let (item_id, username) = make_public_item(&mut h, "suspbtn", "Suspended Title", "digital", 500).await; sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'test' WHERE username = $1") .bind(&username) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/i/{item_id}/button")).await; assert_eq!(resp.status.as_u16(), 404, "Suspended creator's embed must 404"); } #[tokio::test] async fn item_button_returns_404_for_nonexistent_item() { let mut h = TestHarness::new().await; let bogus = "00000000-0000-0000-0000-000000000000"; let resp = h.client.get(&format!("/embed/i/{bogus}/button")).await; assert_eq!(resp.status.as_u16(), 404); } #[tokio::test] async fn item_button_free_item_renders_get_button() { let mut h = TestHarness::new().await; let (item_id, _) = make_public_item(&mut h, "freebtn", "Free Title", "digital", 0).await; let resp = h.client.get(&format!("/embed/i/{item_id}/button")).await; assert!(resp.status.is_success()); assert!(resp.text.contains("Free"), "Free items show 'Free' price label"); assert!(resp.text.contains("Get"), "Free items show 'Get' button (not Buy)"); } #[tokio::test] async fn item_button_pwyw_renders_plus_suffix() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("pwywbtn", "digital", 500).await; sqlx::query( "UPDATE items SET title = 'PWYW Title', is_public = true, pwyw_enabled = true, \ pwyw_min_cents = 500 WHERE id = $1::uuid", ) .bind(&setup.item_id) .execute(&h.db) .await .unwrap(); sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/i/{}/button", setup.item_id)).await; assert!(resp.status.is_success()); assert!( resp.text.contains("$5.00+"), "PWYW pricing should append `+` to the price" ); } // ───────────────────────── /embed/i/{id}/card ──────────────────────────── #[tokio::test] async fn item_card_renders_with_vertical_layout_by_default() { let mut h = TestHarness::new().await; let (item_id, _) = make_public_item(&mut h, "card1", "Card Title", "digital", 1500).await; let resp = h.client.get(&format!("/embed/i/{item_id}/card")).await; assert!(resp.status.is_success(), "{} {}", resp.status, resp.text); assert!(resp.text.contains("Card Title")); assert!(resp.text.contains("$15.00")); // Vertical layout sets flex-direction: column. assert!( resp.text.contains("column"), "Default layout should be vertical (flex-direction: column)" ); assert_embed_headers(&resp, "item card"); } #[tokio::test] async fn item_card_horizontal_layout_query_param() { let mut h = TestHarness::new().await; let (item_id, _) = make_public_item(&mut h, "cardh", "Horiz Title", "digital", 1000).await; let resp = h.client.get(&format!("/embed/i/{item_id}/card?layout=horizontal")).await; assert!(resp.status.is_success()); // Horizontal layout switches to flex-direction: row. assert!( resp.text.contains("row"), "layout=horizontal should set flex-direction: row" ); } #[tokio::test] async fn item_card_escapes_title_for_xss() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("xss1", "digital", 1000).await; // Inject a script tag in the title — the embed must HTML-escape it. sqlx::query("UPDATE items SET title = '', is_public = true WHERE id = $1::uuid") .bind(&setup.item_id) .execute(&h.db) .await .unwrap(); sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/i/{}/card", setup.item_id)).await; assert!(resp.status.is_success()); assert!( !resp.text.contains(""), "Raw script tag must NOT appear in embed output" ); // Askama autoescapes to numeric character references (`<`/`>`), // which are as safe as the old hand-roller's named entities (`<`/`>`). assert!( resp.text.contains("<script>alert(1)</script>"), "Escaped script tag should appear in embed output" ); } // ───────────────────────── /embed/i/{id}/player ────────────────────────── #[tokio::test] async fn item_player_renders_for_item_with_audio() { let mut h = TestHarness::new().await; let (item_id, _) = make_public_item(&mut h, "audplay", "Audio Title", "audio", 1000).await; // Player gates on `audio_s3_key.is_some()`, so we plant a fake key. sqlx::query("UPDATE items SET audio_s3_key = 'fake/key.mp3' WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await; assert!(resp.status.is_success(), "{} {}", resp.status, resp.text); assert!(resp.text.contains("Audio Title")); // Player has a play button + progress bar. assert!(resp.text.contains("play-btn")); assert!(resp.text.contains("progress-bar")); assert_embed_headers(&resp, "item player"); } #[tokio::test] async fn item_player_returns_404_for_item_without_audio() { let mut h = TestHarness::new().await; // No audio_s3_key set — the player must 404. let (item_id, _) = make_public_item(&mut h, "noaudio", "No Audio", "digital", 1000).await; let resp = h.client.get(&format!("/embed/i/{item_id}/player")).await; assert_eq!( resp.status.as_u16(), 404, "Audio player must 404 when item has no audio_s3_key" ); } // ───────────────────────── /embed/p/{slug}/card ────────────────────────── #[tokio::test] async fn project_card_renders_with_iframe_headers() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("projcard", "digital", 1000).await; sqlx::query("UPDATE projects SET title = 'Project Card Title', is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await; assert!(resp.status.is_success(), "{} {}", resp.status, resp.text); assert!(resp.text.contains("Project Card Title")); assert_embed_headers(&resp, "project card"); } #[tokio::test] async fn project_card_returns_404_for_private_project() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("privproj", "digital", 1000).await; sqlx::query("UPDATE projects SET is_public = false WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/embed/p/{}/card", setup.slug)).await; assert_eq!( resp.status.as_u16(), 404, "Private project embed must 404" ); } #[tokio::test] async fn project_card_returns_404_for_nonexistent_slug() { let mut h = TestHarness::new().await; let resp = h.client.get("/embed/p/no-such-project/card").await; assert_eq!(resp.status.as_u16(), 404); } // ───────────────────────── /embed/u/{user}/tip ─────────────────────────── #[tokio::test] async fn tip_button_renders_when_tips_enabled() { let mut h = TestHarness::new().await; let user_id = h.create_creator("tipper").await; // `tips_enabled` defaults to false — tip embed gates on this. sqlx::query("UPDATE users SET tips_enabled = true WHERE id = $1") .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/embed/u/tipper/tip").await; assert!(resp.status.is_success(), "{} {}", resp.status, resp.text); assert!(resp.text.contains("tipper"), "Tip embed should include the creator's username"); assert_embed_headers(&resp, "tip button"); } #[tokio::test] async fn tip_button_returns_404_when_tips_disabled() { let mut h = TestHarness::new().await; // Default for a newly-created creator is `tips_enabled = false`. h.create_creator("notipper").await; let resp = h.client.get("/embed/u/notipper/tip").await; assert_eq!( resp.status.as_u16(), 404, "Tip embed must 404 when the creator hasn't opted in" ); } #[tokio::test] async fn tip_button_returns_404_for_suspended_creator() { let mut h = TestHarness::new().await; let user_id = h.create_creator("suspendtip").await; sqlx::query( "UPDATE users SET tips_enabled = true, suspended_at = NOW(), \ suspension_reason = 'test' WHERE id = $1", ) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/embed/u/suspendtip/tip").await; assert_eq!( resp.status.as_u16(), 404, "Suspended creator's tip embed must 404 even with tips_enabled" ); } #[tokio::test] async fn tip_button_returns_404_for_invalid_username() { let mut h = TestHarness::new().await; // `Username::new` validation rejects this shape — handler returns 404. let resp = h.client.get("/embed/u/...not_a_username.../tip").await; assert_eq!(resp.status.as_u16(), 404); }