//! Integration tests for project and item creation wizards. use crate::harness::TestHarness; // ============================================================================= // Project Wizard // ============================================================================= #[tokio::test] async fn project_wizard_full_flow() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("pwiz").await; // Load wizard page let resp = h.client.get("/dashboard/new-project").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Basics"), "Should show step 1"); // Step 1: Basics — creates project let resp = h .client .post_form( "/dashboard/new-project/step/basics", "title=Wizard+Test&slug=wizard-test&project_type=blog&description=A+test+project", ) .await; assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text); assert!( resp.text.contains("Appearance"), "Should advance to step 2" ); // Verify project created in DB let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM projects WHERE slug = 'wizard-test')", ) .fetch_one(&h.db) .await .unwrap(); assert!(exists, "Project should exist"); // Step 2: Appearance — skip (no cover image) let resp = h .client .post_form("/dashboard/new-project/wizard-test/step/appearance", "") .await; assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text); assert!( resp.text.contains("Monetization"), "Should advance to step 3" ); // Step 3: Monetization — skip (no tiers). `pricing_model` is required by // `save_monetization` (launch-eve hardening: closed silent fallback to // Free); the wizard select defaults to "free" in the rendered form, so // mirror that here. let resp = h .client .post_form( "/dashboard/new-project/wizard-test/step/monetization", "pricing_model=free", ) .await; assert!(resp.status.is_success(), "Step 3 failed: {}", resp.text); // Step 4: First content — skip let resp = h .client .post_form( "/dashboard/new-project/wizard-test/step/first-content", "", ) .await; assert!(resp.status.is_success(), "Step 4 failed: {}", resp.text); // Step 5: Preview — publish let resp = h .client .post_form( "/dashboard/new-project/wizard-test/step/preview", "action=publish", ) .await; // Preview step returns HX-Redirect assert!( resp.headers .get("hx-redirect") .is_some_and(|v| v.to_str().unwrap().contains("wizard-test")), "Should redirect to project dashboard" ); // Verify project is public (publish action confirms is_public = true) let is_public: bool = sqlx::query_scalar("SELECT is_public FROM projects WHERE slug = 'wizard-test'") .fetch_one(&h.db) .await .unwrap(); assert!(is_public, "Project should be published"); } #[tokio::test] async fn project_wizard_save_as_draft() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("pdraft").await; // Step 1: Create project h.client .post_form( "/dashboard/new-project/step/basics", "title=Draft+Project&slug=draft-proj&project_type=music", ) .await; // Skip through steps 2-4 h.client .post_form("/dashboard/new-project/draft-proj/step/appearance", "") .await; h.client .post_form( "/dashboard/new-project/draft-proj/step/monetization", "", ) .await; h.client .post_form( "/dashboard/new-project/draft-proj/step/first-content", "", ) .await; // Step 5: Save as draft (redirects to project dashboard without changing state) let resp = h .client .post_form( "/dashboard/new-project/draft-proj/step/preview", "action=draft", ) .await; assert!( resp.headers .get("hx-redirect") .is_some_and(|v| v.to_str().unwrap().contains("draft-proj")), "Should redirect to project dashboard" ); // Verify project exists let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM projects WHERE slug = 'draft-proj')", ) .fetch_one(&h.db) .await .unwrap(); assert!(exists, "Draft project should exist"); } // ============================================================================= // Item Wizard // ============================================================================= #[tokio::test] async fn item_wizard_full_flow() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("iwiz").await; // Create a project first via API let resp = h .client .post_form("/api/projects", "slug=iwiz-proj&title=Item+Wizard+Test") .await; assert!(resp.status.is_success()); // Load item wizard page let resp = h.client.get("/dashboard/project/iwiz-proj/new-item").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Type"), "Should show step 1 (type)"); // Step 1: Type — creates item let resp = h .client .post_form( "/dashboard/project/iwiz-proj/new-item/step/type", "item_type=text", ) .await; assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text); assert!(resp.text.contains("Basics"), "Should advance to step 2 (basics)"); // Extract item ID from DB let item_id: String = sqlx::query_scalar( "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'iwiz-proj' ORDER BY i.created_at DESC LIMIT 1", ) .fetch_one(&h.db) .await .unwrap(); // Step 2: Basics let resp = h .client .post_form( &format!( "/dashboard/project/iwiz-proj/new-item/{}/step/basics", item_id ), "title=My+First+Article&description=A+great+article", ) .await; assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text); assert!(resp.text.contains("Content"), "Should advance to step 3 (content)"); // Step 3: Content (text body) let resp = h .client .post_form( &format!( "/dashboard/project/iwiz-proj/new-item/{}/step/content", item_id ), "body=Hello+world", ) .await; assert!(resp.status.is_success(), "Step 3 failed: {}", resp.text); assert!(resp.text.contains("Pricing"), "Should advance to step 4 (pricing)"); // Step 4: Pricing (free) let resp = h .client .post_form( &format!( "/dashboard/project/iwiz-proj/new-item/{}/step/pricing", item_id ), "pricing_model=free", ) .await; assert!(resp.status.is_success(), "Step 4 failed: {}", resp.text); // Step 5: Preview — publish let resp = h .client .post_form( &format!( "/dashboard/project/iwiz-proj/new-item/{}/step/preview", item_id ), "action=publish", ) .await; assert!( resp.headers.get("hx-redirect").is_some(), "Should redirect after publish" ); // Verify item is published let is_public: bool = sqlx::query_scalar(&format!( "SELECT is_public FROM items WHERE id = '{}'", item_id )) .fetch_one(&h.db) .await .unwrap(); assert!(is_public, "Item should be published"); } #[tokio::test] async fn item_wizard_pricing_models() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("ipricing").await; let resp = h .client .post_form( "/api/projects", "slug=ipricing-proj&title=Pricing+Test", ) .await; assert!(resp.status.is_success()); // Create item via wizard step 1 h.client .post_form( "/dashboard/project/ipricing-proj/new-item/step/type", "item_type=digital", ) .await; let item_id: String = sqlx::query_scalar( "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'ipricing-proj' ORDER BY i.created_at DESC LIMIT 1", ) .fetch_one(&h.db) .await .unwrap(); // Test fixed pricing let resp = h .client .post_form( &format!( "/dashboard/project/ipricing-proj/new-item/{}/step/pricing", item_id ), "pricing_model=fixed&price=9.99", ) .await; assert!(resp.status.is_success(), "Fixed pricing failed"); let price: i32 = sqlx::query_scalar(&format!( "SELECT price_cents FROM items WHERE id = '{}'", item_id )) .fetch_one(&h.db) .await .unwrap(); assert_eq!(price, 999, "Price should be 999 cents"); // Navigate back to pricing and test PWYW let resp = h .client .post_form( &format!( "/dashboard/project/ipricing-proj/new-item/{}/step/pricing", item_id ), "pricing_model=pwyw&suggested_price=5.00&min_price=1.00", ) .await; assert!(resp.status.is_success(), "PWYW pricing failed"); let (pwyw_enabled, price_cents, min_cents): (bool, i32, Option) = sqlx::query_as(&format!( "SELECT pwyw_enabled, price_cents, pwyw_min_cents FROM items WHERE id = '{}'", item_id )) .fetch_one(&h.db) .await .unwrap(); assert!(pwyw_enabled, "PWYW should be enabled"); assert_eq!(price_cents, 500, "Suggested price should be 500 cents"); assert_eq!(min_cents, Some(100), "Min price should be 100 cents"); // Test free pricing let resp = h .client .post_form( &format!( "/dashboard/project/ipricing-proj/new-item/{}/step/pricing", item_id ), "pricing_model=free", ) .await; assert!(resp.status.is_success(), "Free pricing failed"); let (price, pwyw): (i32, bool) = sqlx::query_as(&format!( "SELECT price_cents, pwyw_enabled FROM items WHERE id = '{}'", item_id )) .fetch_one(&h.db) .await .unwrap(); assert_eq!(price, 0, "Price should be 0 for free"); assert!(!pwyw, "PWYW should be disabled"); } #[tokio::test] async fn item_wizard_schedule_publish() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("isched").await; let resp = h .client .post_form( "/api/projects", "slug=isched-proj&title=Schedule+Test", ) .await; assert!(resp.status.is_success()); // Create item h.client .post_form( "/dashboard/project/isched-proj/new-item/step/type", "item_type=audio", ) .await; let item_id: String = sqlx::query_scalar( "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'isched-proj' LIMIT 1", ) .fetch_one(&h.db) .await .unwrap(); // Schedule publish via preview step let resp = h .client .post_form( &format!( "/dashboard/project/isched-proj/new-item/{}/step/preview", item_id ), "action=schedule&publish_at=2030-01-15T10%3A00", ) .await; assert!( resp.headers.get("hx-redirect").is_some(), "Should redirect after schedule" ); // Verify publish_at is set let has_publish_at: bool = sqlx::query_scalar(&format!( "SELECT publish_at IS NOT NULL FROM items WHERE id = '{}'", item_id )) .fetch_one(&h.db) .await .unwrap(); assert!(has_publish_at, "publish_at should be set for scheduled item"); } // ============================================================================= // Auth & Access Control // ============================================================================= #[tokio::test] async fn wizard_auth_required() { let mut h = TestHarness::new().await; // Unauthenticated access should redirect to login let resp = h.client.get("/dashboard/new-project").await; assert!( resp.status.is_redirection() || resp.status == 401 || resp.status == 403, "Unauthenticated user should not access wizard, got {}", resp.status ); let resp = h .client .get("/dashboard/project/anything/new-item") .await; assert!( resp.status.is_redirection() || resp.status == 401 || resp.status == 403, "Unauthenticated user should not access item wizard, got {}", resp.status ); } #[tokio::test] async fn wizard_ownership_check() { let mut h = TestHarness::new().await; // Creator A creates a project let _user_a = h.create_creator("wizown_a").await; let resp = h .client .post_form("/api/projects", "slug=owned-proj&title=Owned") .await; assert!(resp.status.is_success()); // Create item via wizard h.client .post_form( "/dashboard/project/owned-proj/new-item/step/type", "item_type=text", ) .await; let item_id: String = sqlx::query_scalar( "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'owned-proj' LIMIT 1", ) .fetch_one(&h.db) .await .unwrap(); // Creator B logs in h.client.post_form("/logout", "").await; let _user_b = h.create_creator("wizown_b").await; // Creator B should not access A's item wizard step let resp = h .client .get(&format!( "/dashboard/project/owned-proj/new-item/{}/step/details", item_id )) .await; assert!( resp.status == 404 || resp.status == 403, "Non-owner should get 404 or 403, got {}", resp.status ); // Creator B should not access A's project wizard step let resp = h .client .get("/dashboard/new-project/owned-proj/step/appearance") .await; assert!( resp.status == 404 || resp.status == 403, "Non-owner should not access other's project wizard, got {}", resp.status ); } #[tokio::test] async fn wizard_back_navigation() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("wback").await; // Create project via wizard let resp = h .client .post_form( "/dashboard/new-project/step/basics", "title=Back+Nav&slug=back-nav&project_type=general", ) .await; assert!(resp.status.is_success()); // Go forward to monetization h.client .post_form("/dashboard/new-project/back-nav/step/appearance", "") .await; // Navigate back to basics step via GET let resp = h .client .get("/dashboard/new-project/back-nav/step/basics") .await; assert!(resp.status.is_success(), "GET basics step failed: {} {}", resp.status, resp.text); assert!( resp.text.contains("Back Nav"), "Should show saved title when navigating back" ); // Navigate back to appearance let resp = h .client .get("/dashboard/new-project/back-nav/step/appearance") .await; assert!(resp.status.is_success()); } // ============================================================================= // Wizard Features // ============================================================================= #[tokio::test] async fn item_wizard_license_keys() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("ilickey").await; let resp = h .client .post_form("/api/projects", "slug=lickey-proj&title=License+Test") .await; assert!(resp.status.is_success()); // Create plugin item (license keys relevant for this type) h.client .post_form( "/dashboard/project/lickey-proj/new-item/step/type", "item_type=plugin", ) .await; let item_id: String = sqlx::query_scalar( "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'lickey-proj' LIMIT 1", ) .fetch_one(&h.db) .await .unwrap(); // Enable license keys via the API (no longer a wizard step) let resp = h .client .put_form( &format!("/api/items/{}/license-settings", item_id), "enable_license_keys=on&default_max_activations=5", ) .await; assert!( resp.status.is_success(), "License settings update failed: {}", resp.text ); // Verify license settings saved let (enabled, max_act): (bool, Option) = sqlx::query_as(&format!( "SELECT enable_license_keys, default_max_activations FROM items WHERE id = '{}'", item_id )) .fetch_one(&h.db) .await .unwrap(); assert!(enabled, "License keys should be enabled"); assert_eq!(max_act, Some(5), "Max activations should be 5"); } #[tokio::test] async fn project_wizard_subscription_tiers() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("ptiers").await; // Create project via wizard h.client .post_form( "/dashboard/new-project/step/basics", "title=Tier+Test&slug=tier-test&project_type=podcast", ) .await; // Skip appearance h.client .post_form("/dashboard/new-project/tier-test/step/appearance", "") .await; // Create tiers in monetization step let resp = h .client .post_form( "/dashboard/new-project/tier-test/step/monetization", "pricing_model=subscription&tier_name_0=Basic&tier_price_0=5.00&tier_desc_0=Basic+access&tier_name_1=Pro&tier_price_1=15.00&tier_desc_1=Full+access", ) .await; assert!( resp.status.is_success(), "Monetization step failed: {}", resp.text ); // Verify tiers created let tier_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscription_tiers t JOIN projects p ON t.project_id = p.id WHERE p.slug = 'tier-test'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(tier_count, 2, "Should have created 2 tiers"); } // ============================================================================= // Join Wizard // ============================================================================= #[tokio::test] async fn join_wizard_full_flow() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; // Load wizard page let resp = h.client.get("/join").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Create account"), "Should show step 1"); assert!(resp.text.contains("wizard-steps"), "Should have step nav"); // Step 1: Account — creates user and logs in let resp = h .client .post_form( "/join/step/account", "username=jwiz&email=jwiz@test.com&password=testpassword123", ) .await; assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text); assert!(resp.text.contains("Profile"), "Should advance to step 2"); // Verify user created in DB let _user_id: makenotwork::db::UserId = sqlx::query_scalar("SELECT id FROM users WHERE username = 'jwiz'") .fetch_one(&h.db) .await .unwrap(); // Step 2: Profile — update display_name and bio let resp = h .client .post_form( "/join/step/profile", "display_name=Join+Wizard&bio=Testing+the+wizard", ) .await; assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text); // Profile now goes directly to welcome assert!( resp.text.contains("Welcome"), "Should advance to welcome step" ); // Verify profile saved let display_name: Option = sqlx::query_scalar("SELECT display_name FROM users WHERE username = 'jwiz'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(display_name.as_deref(), Some("Join Wizard")); // Welcome page should have intent branching assert!( resp.text.contains("Browse and buy") || resp.text.contains("I want to sell"), "Should show intent options" ); } #[tokio::test] async fn join_wizard_skip_all_optional() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; // Step 1: Create account let resp = h .client .post_form( "/join/step/account", "username=jskip&email=jskip@test.com&password=testpassword123", ) .await; assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text); // Skip directly to complete let resp = h.client.get("/join/step/complete").await; assert!(resp.status.is_success()); assert!(resp.text.contains("Welcome")); // Verify user exists, no profile update, no waitlist entry let display_name: Option = sqlx::query_scalar("SELECT display_name FROM users WHERE username = 'jskip'") .fetch_one(&h.db) .await .unwrap(); assert!(display_name.is_none(), "Display name should not be set"); let has_waitlist: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM creator_waitlist WHERE user_id = (SELECT id FROM users WHERE username = 'jskip'))", ) .fetch_one(&h.db) .await .unwrap(); assert!(!has_waitlist, "No waitlist entry should exist"); } #[tokio::test] async fn join_wizard_with_invite_code() { let mut h = TestHarness::new().await; // Create a creator who can issue invites let creator_id = h.create_creator("jinviter").await; // Create an invite code directly in DB sqlx::query( "INSERT INTO invite_codes (creator_id, code) VALUES ($1, 'TESTCODE')", ) .bind(creator_id) .execute(&h.db) .await .unwrap(); // Log out the creator h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; // Load join page with invite code let resp = h.client.get("/join?invite=TESTCODE").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("invited"), "Should show invite notice"); // Sign up with invite code let resp = h .client .post_form( "/join/step/account", "username=jinvitee&email=jinvitee@test.com&password=testpassword123&invite_code=TESTCODE", ) .await; assert!(resp.status.is_success(), "Signup with invite failed: {}", resp.text); // Verify invite redeemed let redeemed: bool = sqlx::query_scalar( "SELECT redeemed_by_id IS NOT NULL FROM invite_codes WHERE code = 'TESTCODE'", ) .fetch_one(&h.db) .await .unwrap(); assert!(redeemed, "Invite should be redeemed"); // Verify waitlist entry created (invited type) let has_waitlist: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM creator_waitlist WHERE user_id = (SELECT id FROM users WHERE username = 'jinvitee') AND selection_method = 'invited')", ) .fetch_one(&h.db) .await .unwrap(); assert!(has_waitlist, "Invited waitlist entry should exist"); } #[tokio::test] async fn join_wizard_validation_errors() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; // Short password (HTMX: returns error fragment with 200; non-HTMX: returns 422) let resp = h .client .htmx_post_form( "/join/step/account", "username=jval&email=jval@test.com&password=short", ) .await; assert!(resp.status.is_success()); assert!( resp.text.contains("8 characters"), "Should show password error" ); // Bad email let resp = h .client .htmx_post_form( "/join/step/account", "username=jval&email=bademail&password=testpassword123", ) .await; assert!(resp.status.is_success()); assert!( resp.text.contains("valid email"), "Should show email error" ); // Create a user, then try duplicate username h.signup("jexisting", "jexisting@test.com", "testpassword123").await; h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h .client .htmx_post_form( "/join/step/account", "username=jexisting&email=other@test.com&password=testpassword123", ) .await; assert!(resp.status.is_success()); assert!( resp.text.contains("already taken"), "Should show duplicate username error" ); } #[tokio::test] async fn join_wizard_auth_required_after_step1() { let mut h = TestHarness::new().await; // Unauthenticated GET to step 2 should redirect/fail let resp = h.client.get("/join/step/profile").await; assert!( resp.status.is_redirection() || resp.status == 401 || resp.status == 403, "Unauthenticated step access should fail, got {}", resp.status ); // Unauthenticated POST to step 2 should redirect/fail let resp = h .client .post_form("/join/step/profile", "display_name=test") .await; assert!( resp.status.is_redirection() || resp.status == 401 || resp.status == 403, "Unauthenticated step POST should fail, got {}", resp.status ); } #[tokio::test] async fn join_wizard_redirect_if_logged_in() { let mut h = TestHarness::new().await; h.signup("jloggedin", "jloggedin@test.com", "testpassword123").await; // GET /join while logged in should redirect to /dashboard let resp = h.client.get("/join").await; assert!( resp.status.is_redirection(), "Logged-in user should be redirected, got {}", resp.status ); } #[tokio::test] async fn join_wizard_removed_steps_return_404() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; // Create account let resp = h .client .post_form( "/join/step/account", "username=jpitch&email=jpitch@test.com&password=testpassword123", ) .await; assert!(resp.status.is_success()); // Pitch and stripe steps no longer exist (removed in onboarding overhaul) let resp = h.client.get("/join/step/pitch").await; assert_eq!(resp.status, 404, "Pitch step should be removed"); let resp = h.client.get("/join/step/stripe").await; assert_eq!(resp.status, 404, "Stripe step should be removed"); } /// A monetization step with a VALID pricing change but an INVALID tier price /// must persist nothing. Previously `update_project_pricing` committed before /// the tier loop ran, so a malformed tier left the pricing change applied with /// no tiers and the user on an error page (Run #11 UX MINOR — non-atomic step). /// The handler now validates every tier row before any write. #[tokio::test] async fn project_wizard_monetization_invalid_tier_persists_nothing() { let mut h = TestHarness::new().await; h.create_creator("monatomic").await; let resp = h .client .post_form( "/dashboard/new-project/step/basics", "title=Mon+Atomic&slug=mon-atomic&project_type=blog&description=x", ) .await; assert!(resp.status.is_success(), "basics failed: {}", resp.text); let before: String = sqlx::query_scalar("SELECT pricing_model FROM projects WHERE slug = 'mon-atomic'") .fetch_one(&h.db) .await .unwrap(); // Valid pricing (buy_once $5) + an unparseable tier price. let resp = h .client .post_form( "/dashboard/new-project/mon-atomic/step/monetization", "pricing_model=buy_once&price_dollars=5&tier_name_0=Gold&tier_price_0=notaprice", ) .await; assert!( !resp.status.is_success(), "an invalid tier price must reject the whole step: {} {}", resp.status, resp.text ); let after: String = sqlx::query_scalar("SELECT pricing_model FROM projects WHERE slug = 'mon-atomic'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(after, before, "a rejected monetization step must not persist the pricing change"); assert_ne!(after, "buy_once", "the partial pricing write must have been prevented"); let tier_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscription_tiers t JOIN projects p ON p.id = t.project_id WHERE p.slug = 'mon-atomic'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(tier_count, 0, "no tiers should persist from a rejected step"); } /// `save_appearance` validates the client-supplied cover URL against the CDN /// base. The check must use a path boundary (`{cdn_base}/`) so a host-prefix /// confusion (`cdn.makenot.work.attacker.com`) can't slip past (Run #12 UX fix). #[tokio::test] async fn project_wizard_appearance_rejects_non_cdn_cover_url() { let mut h = TestHarness::build(crate::harness::BuildOptions { cdn_base_url: Some("https://cdn.makenot.work".to_string()), ..Default::default() }) .await; h.create_creator("cdncreator").await; let resp = h .client .post_form( "/dashboard/new-project/step/basics", "title=CDN+Test&slug=cdn-test&project_type=blog&description=x", ) .await; assert!(resp.status.is_success(), "basics failed: {}", resp.text); // Host-prefix confusion: starts with the CDN base as a bare string prefix but // is a different host. Must be rejected. let resp = h .client .post_form( "/dashboard/new-project/cdn-test/step/appearance", "cover_image_url=https://cdn.makenot.work.attacker.com/x.jpg", ) .await; assert!(resp.status.is_client_error(), "hostile cover URL must be rejected: {} {}", resp.status, resp.text); let stored: Option = sqlx::query_scalar("SELECT cover_image_url FROM projects WHERE slug = 'cdn-test'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(stored, None, "hostile URL must not be persisted"); // A genuine CDN URL is accepted. let resp = h .client .post_form( "/dashboard/new-project/cdn-test/step/appearance", "cover_image_url=https://cdn.makenot.work/projects/abc/cover.jpg", ) .await; assert!(resp.status.is_success(), "valid CDN cover URL should be accepted: {} {}", resp.status, resp.text); let stored: Option = sqlx::query_scalar("SELECT cover_image_url FROM projects WHERE slug = 'cdn-test'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(stored.as_deref(), Some("https://cdn.makenot.work/projects/abc/cover.jpg")); }