max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
22 files changed,
+1257 insertions,
-167 deletions
| @@ -304,35 +304,6 @@ | |||
| 304 | 304 | } | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | - | #[cfg(test)] | |
| 308 | - | mod timeout_exempt_tests { | |
| 309 | - | use super::timeout_exempt; | |
| 310 | - | ||
| 311 | - | #[test] | |
| 312 | - | fn exempts_only_anchored_long_running_routes() { | |
| 313 | - | // Genuinely long / streaming routes stay exempt. | |
| 314 | - | for p in [ | |
| 315 | - | "/git/foo/bar.git/info/refs", | |
| 316 | - | "/api/export/content", | |
| 317 | - | "/api/internal/creator/export/sales", | |
| 318 | - | "/api/sync/subscribe", | |
| 319 | - | "/api/v1/sync/subscribe", | |
| 320 | - | "/api/sync/ota/apps/x/releases", | |
| 321 | - | "/api/v1/sync/ota/slug/macos/arm64/1.0.0", | |
| 322 | - | ] { | |
| 323 | - | assert!(timeout_exempt(p), "{p} should be exempt"); | |
| 324 | - | } | |
| 325 | - | // The substring-match hazard: these contain a token but must NOT be exempt. | |
| 326 | - | for p in [ | |
| 327 | - | "/dashboard/export", // a normal page, not a long export | |
| 328 | - | "/u/somecreator/export-notes", // creator-controlled slug | |
| 329 | - | "/items/sync/ota-recap", // happens to contain the token mid-path | |
| 330 | - | ] { | |
| 331 | - | assert!(!timeout_exempt(p), "{p} must not be exempt"); | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | ||
| 336 | 307 | /// Middleware that sets security headers on all responses. | |
| 337 | 308 | /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding. | |
| 338 | 309 | async fn security_headers_middleware( | |
| @@ -429,3 +400,32 @@ | |||
| 429 | 400 | ); | |
| 430 | 401 | response | |
| 431 | 402 | } | |
| 403 | + | ||
| 404 | + | #[cfg(test)] | |
| 405 | + | mod timeout_exempt_tests { | |
| 406 | + | use super::timeout_exempt; | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn exempts_only_anchored_long_running_routes() { | |
| 410 | + | // Genuinely long / streaming routes stay exempt. | |
| 411 | + | for p in [ | |
| 412 | + | "/git/foo/bar.git/info/refs", | |
| 413 | + | "/api/export/content", | |
| 414 | + | "/api/internal/creator/export/sales", | |
| 415 | + | "/api/sync/subscribe", | |
| 416 | + | "/api/v1/sync/subscribe", | |
| 417 | + | "/api/sync/ota/apps/x/releases", | |
| 418 | + | "/api/v1/sync/ota/slug/macos/arm64/1.0.0", | |
| 419 | + | ] { | |
| 420 | + | assert!(timeout_exempt(p), "{p} should be exempt"); | |
| 421 | + | } | |
| 422 | + | // The substring-match hazard: these contain a token but must NOT be exempt. | |
| 423 | + | for p in [ | |
| 424 | + | "/dashboard/export", // a normal page, not a long export | |
| 425 | + | "/u/somecreator/export-notes", // creator-controlled slug | |
| 426 | + | "/items/sync/ota-recap", // happens to contain the token mid-path | |
| 427 | + | ] { | |
| 428 | + | assert!(!timeout_exempt(p), "{p} must not be exempt"); | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | } |
| @@ -93,6 +93,34 @@ | |||
| 93 | 93 | Ok(key) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | /// Look up the auto-minted license key for a purchase transaction, if any. | |
| 97 | + | /// | |
| 98 | + | /// Used by the finalize pre-check so a crash-recovery redelivery does not mint | |
| 99 | + | /// a second key (the `license_keys_transaction_id_key` partial unique index is | |
| 100 | + | /// the structural backstop). At most one such key exists per transaction. | |
| 101 | + | #[tracing::instrument(skip_all)] | |
| 102 | + | pub async fn get_license_key_by_transaction_id( | |
| 103 | + | pool: &PgPool, | |
| 104 | + | transaction_id: TransactionId, | |
| 105 | + | ) -> Result<Option<DbLicenseKey>> { | |
| 106 | + | let key = sqlx::query_as!( | |
| 107 | + | DbLicenseKey, | |
| 108 | + | r#" | |
| 109 | + | SELECT id AS "id: LicenseKeyId", item_id AS "item_id: ItemId", | |
| 110 | + | owner_id AS "owner_id: UserId", transaction_id AS "transaction_id: TransactionId", | |
| 111 | + | key_code AS "key_code: KeyCode", max_activations, activation_count, | |
| 112 | + | revoked_at AS "revoked_at: chrono::DateTime<chrono::Utc>", | |
| 113 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>" | |
| 114 | + | FROM license_keys WHERE transaction_id = $1 | |
| 115 | + | "#, | |
| 116 | + | transaction_id as TransactionId, | |
| 117 | + | ) | |
| 118 | + | .fetch_optional(pool) | |
| 119 | + | .await?; | |
| 120 | + | ||
| 121 | + | Ok(key) | |
| 122 | + | } | |
| 123 | + | ||
| 96 | 124 | /// Get a license key by ID. | |
| 97 | 125 | #[tracing::instrument(skip_all)] | |
| 98 | 126 | pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result<Option<DbLicenseKey>> { |
| @@ -248,10 +248,13 @@ | |||
| 248 | 248 | let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect(); | |
| 249 | 249 | let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a as i32).collect(); | |
| 250 | 250 | let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect(); | |
| 251 | + | // ON CONFLICT DO NOTHING (against revenue_splits_tx_recipient_key): a | |
| 252 | + | // crash-recovery finalize re-run records no duplicate splits. | |
| 251 | 253 | sqlx::query( | |
| 252 | 254 | r#" | |
| 253 | 255 | INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status) | |
| 254 | 256 | SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending' | |
| 257 | + | ON CONFLICT (transaction_id, recipient_id) DO NOTHING | |
| 255 | 258 | "#, | |
| 256 | 259 | ) | |
| 257 | 260 | .bind(transaction_id) |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | ||
| 5 | 5 | use super::id_types::*; | |
| 6 | 6 | use super::models::*; | |
| 7 | + | use super::validated_types::Cents; | |
| 7 | 8 | use crate::error::Result; | |
| 8 | 9 | ||
| 9 | 10 | /// Create a pending tip record before redirecting to Stripe Checkout. | |
| @@ -17,19 +18,26 @@ | |||
| 17 | 18 | message: Option<&str>, | |
| 18 | 19 | stripe_checkout_session_id: &str, | |
| 19 | 20 | ) -> Result<DbTip> { | |
| 20 | - | let tip = sqlx::query_as::<_, DbTip>( | |
| 21 | + | let tip = sqlx::query_as!( | |
| 22 | + | DbTip, | |
| 21 | 23 | r#" | |
| 22 | 24 | INSERT INTO tips (tipper_id, recipient_id, project_id, amount_cents, message, stripe_checkout_session_id) | |
| 23 | 25 | VALUES ($1, $2, $3, $4, $5, $6) | |
| 24 | - | RETURNING * | |
| 26 | + | RETURNING | |
| 27 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 28 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 29 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 30 | + | stripe_transfer_group, | |
| 31 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 32 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 25 | 33 | "#, | |
| 34 | + | tipper_id as UserId, | |
| 35 | + | recipient_id as UserId, | |
| 36 | + | project_id as Option<ProjectId>, | |
| 37 | + | amount_cents, | |
| 38 | + | message, | |
| 39 | + | stripe_checkout_session_id, | |
| 26 | 40 | ) | |
| 27 | - | .bind(tipper_id) | |
| 28 | - | .bind(recipient_id) | |
| 29 | - | .bind(project_id) | |
| 30 | - | .bind(amount_cents) | |
| 31 | - | .bind(message) | |
| 32 | - | .bind(stripe_checkout_session_id) | |
| 33 | 41 | .fetch_one(pool) | |
| 34 | 42 | .await?; | |
| 35 | 43 | ||
| @@ -39,12 +47,13 @@ | |||
| 39 | 47 | /// Mark a tip as completed after Stripe confirms payment. | |
| 40 | 48 | /// Returns `Some(tip)` if updated, `None` if already completed (idempotent). | |
| 41 | 49 | #[tracing::instrument(skip(executor))] | |
| 42 | - | pub async fn complete_tip( | |
| 43 | - | executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>, | |
| 50 | + | pub async fn complete_tip<'e>( | |
| 51 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 44 | 52 | stripe_checkout_session_id: &str, | |
| 45 | 53 | stripe_payment_intent_id: &str, | |
| 46 | 54 | ) -> Result<Option<DbTip>> { | |
| 47 | - | let tip = sqlx::query_as::<_, DbTip>( | |
| 55 | + | let tip = sqlx::query_as!( | |
| 56 | + | DbTip, | |
| 48 | 57 | r#" | |
| 49 | 58 | UPDATE tips | |
| 50 | 59 | SET status = 'completed', | |
| @@ -52,11 +61,17 @@ | |||
| 52 | 61 | completed_at = NOW() | |
| 53 | 62 | WHERE stripe_checkout_session_id = $1 | |
| 54 | 63 | AND status = 'pending' | |
| 55 | - | RETURNING * | |
| 64 | + | RETURNING | |
| 65 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 66 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 67 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 68 | + | stripe_transfer_group, | |
| 69 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 70 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 56 | 71 | "#, | |
| 72 | + | stripe_checkout_session_id, | |
| 73 | + | stripe_payment_intent_id, | |
| 57 | 74 | ) | |
| 58 | - | .bind(stripe_checkout_session_id) | |
| 59 | - | .bind(stripe_payment_intent_id) | |
| 60 | 75 | .fetch_optional(executor) | |
| 61 | 76 | .await?; | |
| 62 | 77 | ||
| @@ -71,10 +86,14 @@ | |||
| 71 | 86 | limit: i64, | |
| 72 | 87 | offset: i64, | |
| 73 | 88 | ) -> Result<Vec<DbTipWithUser>> { | |
| 74 | - | let tips = sqlx::query_as::<_, DbTipWithUser>( | |
| 89 | + | let tips = sqlx::query_as!( | |
| 90 | + | DbTipWithUser, | |
| 75 | 91 | r#" | |
| 76 | - | SELECT t.id, t.tipper_id, t.recipient_id, t.project_id, t.amount_cents, | |
| 77 | - | t.message, t.status, t.created_at, t.completed_at, | |
| 92 | + | SELECT t.id AS "id: TipId", t.tipper_id AS "tipper_id: UserId", t.recipient_id AS "recipient_id: UserId", | |
| 93 | + | t.project_id AS "project_id: ProjectId", t.amount_cents AS "amount_cents: Cents", | |
| 94 | + | t.message, t.status AS "status: super::TransactionStatus", | |
| 95 | + | t.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 96 | + | t.completed_at AS "completed_at: chrono::DateTime<chrono::Utc>", | |
| 78 | 97 | u.username AS tipper_username, u.display_name AS tipper_display_name | |
| 79 | 98 | FROM tips t | |
| 80 | 99 | JOIN users u ON u.id = t.tipper_id | |
| @@ -82,10 +101,10 @@ | |||
| 82 | 101 | ORDER BY t.created_at DESC | |
| 83 | 102 | LIMIT $2 OFFSET $3 | |
| 84 | 103 | "#, | |
| 104 | + | recipient_id as UserId, | |
| 105 | + | limit, | |
| 106 | + | offset, | |
| 85 | 107 | ) | |
| 86 | - | .bind(recipient_id) | |
| 87 | - | .bind(limit) | |
| 88 | - | .bind(offset) | |
| 89 | 108 | .fetch_all(pool) | |
| 90 | 109 | .await?; | |
| 91 | 110 | ||
| @@ -95,41 +114,41 @@ | |||
| 95 | 114 | /// Total tip revenue received by a creator (completed tips only). | |
| 96 | 115 | #[tracing::instrument(skip(pool))] | |
| 97 | 116 | pub async fn total_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> { | |
| 98 | - | let row: (Option<i64>,) = sqlx::query_as( | |
| 99 | - | "SELECT SUM(amount_cents)::BIGINT FROM tips WHERE recipient_id = $1 AND status = 'completed'", | |
| 117 | + | let total = sqlx::query_scalar!( | |
| 118 | + | r#"SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "total!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#, | |
| 119 | + | recipient_id as UserId, | |
| 100 | 120 | ) | |
| 101 | - | .bind(recipient_id) | |
| 102 | 121 | .fetch_one(pool) | |
| 103 | 122 | .await?; | |
| 104 | 123 | ||
| 105 | - | Ok(row.0.unwrap_or(0)) | |
| 124 | + | Ok(total) | |
| 106 | 125 | } | |
| 107 | 126 | ||
| 108 | 127 | /// Count of completed tips received. | |
| 109 | 128 | #[tracing::instrument(skip(pool))] | |
| 110 | 129 | pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> { | |
| 111 | - | let row: (i64,) = sqlx::query_as( | |
| 112 | - | "SELECT COUNT(*) FROM tips WHERE recipient_id = $1 AND status = 'completed'", | |
| 130 | + | let count = sqlx::query_scalar!( | |
| 131 | + | r#"SELECT COUNT(*) AS "count!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#, | |
| 132 | + | recipient_id as UserId, | |
| 113 | 133 | ) | |
| 114 | - | .bind(recipient_id) | |
| 115 | 134 | .fetch_one(pool) | |
| 116 | 135 | .await?; | |
| 117 | 136 | ||
| 118 | - | Ok(row.0) | |
| 137 | + | Ok(count) | |
| 119 | 138 | } | |
| 120 | 139 | ||
| 121 | 140 | /// Mark a tip as refunded by payment intent ID. | |
| 122 | 141 | /// Returns true if a tip was refunded, false if not found (idempotent). | |
| 123 | 142 | #[tracing::instrument(skip(pool))] | |
| 124 | 143 | pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> { | |
| 125 | - | let result = sqlx::query( | |
| 144 | + | let result = sqlx::query!( | |
| 126 | 145 | r#" | |
| 127 | 146 | UPDATE tips | |
| 128 | 147 | SET status = 'refunded' | |
| 129 | 148 | WHERE stripe_payment_intent_id = $1 AND status = 'completed' | |
| 130 | 149 | "#, | |
| 150 | + | payment_intent_id, | |
| 131 | 151 | ) | |
| 132 | - | .bind(payment_intent_id) | |
| 133 | 152 | .execute(pool) | |
| 134 | 153 | .await?; | |
| 135 | 154 | ||
| @@ -145,17 +164,25 @@ | |||
| 145 | 164 | limit: i64, | |
| 146 | 165 | offset: i64, | |
| 147 | 166 | ) -> Result<Vec<DbTip>> { | |
| 148 | - | let tips = sqlx::query_as::<_, DbTip>( | |
| 167 | + | let tips = sqlx::query_as!( | |
| 168 | + | DbTip, | |
| 149 | 169 | r#" | |
| 150 | - | SELECT * FROM tips | |
| 170 | + | SELECT | |
| 171 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 172 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 173 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 174 | + | stripe_transfer_group, | |
| 175 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 176 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 177 | + | FROM tips | |
| 151 | 178 | WHERE tipper_id = $1 AND status = 'completed' | |
| 152 | 179 | ORDER BY created_at DESC | |
| 153 | 180 | LIMIT $2 OFFSET $3 | |
| 154 | 181 | "#, | |
| 182 | + | tipper_id as UserId, | |
| 183 | + | limit, | |
| 184 | + | offset, | |
| 155 | 185 | ) | |
| 156 | - | .bind(tipper_id) | |
| 157 | - | .bind(limit) | |
| 158 | - | .bind(offset) | |
| 159 | 186 | .fetch_all(pool) | |
| 160 | 187 | .await?; | |
| 161 | 188 |
| @@ -292,6 +292,41 @@ | |||
| 292 | 292 | Ok(txs) | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | + | /// Fetch all completed transactions for a checkout session. | |
| 296 | + | /// | |
| 297 | + | /// Used on the crash-recovery branch of the purchase/cart webhook handlers: when | |
| 298 | + | /// `complete_transaction` / `complete_cart_transactions` return nothing (the | |
| 299 | + | /// rows were already flipped to completed by a first attempt that crashed before | |
| 300 | + | /// running finalize), this re-reads those completed rows so finalize can re-run | |
| 301 | + | /// idempotently. Covers single and cart purchases since both key on the session. | |
| 302 | + | #[tracing::instrument(skip_all)] | |
| 303 | + | pub async fn get_completed_transactions_for_session<'e>( | |
| 304 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 305 | + | stripe_checkout_session_id: &str, | |
| 306 | + | ) -> Result<Vec<DbTransaction>> { | |
| 307 | + | let txs = sqlx::query_as!( | |
| 308 | + | DbTransaction, | |
| 309 | + | r#" | |
| 310 | + | SELECT | |
| 311 | + | id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", | |
| 312 | + | item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", | |
| 313 | + | currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 314 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>", | |
| 315 | + | item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", | |
| 316 | + | parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", | |
| 317 | + | guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", | |
| 318 | + | download_token AS "download_token: DownloadToken" | |
| 319 | + | FROM transactions | |
| 320 | + | WHERE stripe_checkout_session_id = $1 AND status = 'completed' | |
| 321 | + | "#, | |
| 322 | + | stripe_checkout_session_id, | |
| 323 | + | ) | |
| 324 | + | .fetch_all(executor) | |
| 325 | + | .await?; | |
| 326 | + | ||
| 327 | + | Ok(txs) | |
| 328 | + | } | |
| 329 | + | ||
| 295 | 330 | /// List transactions where the user is the buyer, newest first. | |
| 296 | 331 | /// | |
| 297 | 332 | /// Pass `limit: None` for all rows (exports), or `Some(n)` for dashboard display. |
| @@ -258,23 +258,36 @@ | |||
| 258 | 258 | // Duplicate creation | |
| 259 | 259 | // ============================================================================= | |
| 260 | 260 | ||
| 261 | - | /// Vulnerability tested: Duplicate project slug creates confusion or overwrites. | |
| 262 | - | /// Second project with same slug should be rejected (unique constraint). | |
| 261 | + | /// Vulnerability tested: a duplicate project slug must never overwrite the | |
| 262 | + | /// existing project or surface a raw 500. The create path auto-suffixes the | |
| 263 | + | /// collision (`inputtest-proj` -> `inputtest-proj-2`) via `insert_with_unique_slug` | |
| 264 | + | /// and retries — the deliberate Run 2 UX seal. The second project gets a fresh | |
| 265 | + | /// id and a distinct slug, so no confusion or overwrite is possible. | |
| 263 | 266 | #[tokio::test] | |
| 264 | - | async fn duplicate_project_slug_rejected() { | |
| 267 | + | async fn duplicate_project_slug_auto_suffixed() { | |
| 265 | 268 | let mut h = TestHarness::new().await; | |
| 266 | - | let (_project_id, _item_id) = setup_creator_with_item(&mut h).await; | |
| 269 | + | let (project_id, _item_id) = setup_creator_with_item(&mut h).await; | |
| 267 | 270 | ||
| 268 | - | // Try creating another project with the same slug | |
| 271 | + | // Creating another project with the same slug succeeds with a suffixed slug. | |
| 269 | 272 | let resp = h | |
| 270 | 273 | .client | |
| 271 | 274 | .post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop") | |
| 272 | 275 | .await; | |
| 273 | 276 | assert!( | |
| 274 | - | !resp.status.is_success(), | |
| 275 | - | "Duplicate slug must not succeed: {} {}", | |
| 277 | + | resp.status.is_success(), | |
| 278 | + | "Duplicate slug should auto-suffix, not fail: {} {}", | |
| 276 | 279 | resp.status, resp.text | |
| 277 | 280 | ); | |
| 281 | + | let second: serde_json::Value = resp.json(); | |
| 282 | + | assert_eq!( | |
| 283 | + | second["slug"], "inputtest-proj-2", | |
| 284 | + | "collision must auto-suffix to inputtest-proj-2: {}", second | |
| 285 | + | ); | |
| 286 | + | assert_ne!( | |
| 287 | + | second["id"].as_str().unwrap(), | |
| 288 | + | project_id, | |
| 289 | + | "the duplicate must be a new project, never an overwrite" | |
| 290 | + | ); | |
| 278 | 291 | } | |
| 279 | 292 | ||
| 280 | 293 | /// Vulnerability tested: Duplicate username on signup. |
| @@ -84,6 +84,7 @@ | |||
| 84 | 84 | mod media_library; | |
| 85 | 85 | mod synckit_sse; | |
| 86 | 86 | mod mock_payment_flows; | |
| 87 | + | mod payment_crash_recovery; | |
| 87 | 88 | mod revenue_splits; | |
| 88 | 89 | mod synckit_selective; | |
| 89 | 90 | mod guest_checkout; |
| @@ -168,7 +168,7 @@ | |||
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | #[tokio::test] | |
| 171 | - | async fn duplicate_slug_rejected() { | |
| 171 | + | async fn duplicate_slug_auto_suffixed() { | |
| 172 | 172 | let mut h = TestHarness::new().await; | |
| 173 | 173 | setup_creator(&mut h, "projslug").await; | |
| 174 | 174 | ||
| @@ -177,17 +177,27 @@ | |||
| 177 | 177 | .post_form("/api/projects", "slug=unique-slug&title=First") | |
| 178 | 178 | .await; | |
| 179 | 179 | assert!(resp.status.is_success()); | |
| 180 | + | let first: serde_json::Value = resp.json(); | |
| 181 | + | assert_eq!(first["slug"], "unique-slug"); | |
| 180 | 182 | ||
| 181 | - | // Same slug should fail (may return 4xx or 5xx depending on error handling) | |
| 183 | + | // A second project with the same slug does NOT 500 or overwrite: the | |
| 184 | + | // create path routes through `insert_with_unique_slug`, which auto-suffixes | |
| 185 | + | // the collision (`unique-slug` -> `unique-slug-2`) and retries. This is the | |
| 186 | + | // deliberate Run 2 UX seal against slug-dedup drift. | |
| 182 | 187 | let resp = h | |
| 183 | 188 | .client | |
| 184 | 189 | .post_form("/api/projects", "slug=unique-slug&title=Second") | |
| 185 | 190 | .await; | |
| 186 | 191 | assert!( | |
| 187 | - | !resp.status.is_success(), | |
| 188 | - | "Duplicate slug should be rejected: {} {}", | |
| 192 | + | resp.status.is_success(), | |
| 193 | + | "Duplicate slug should auto-suffix, not fail: {} {}", | |
| 189 | 194 | resp.status, resp.text | |
| 190 | 195 | ); | |
| 196 | + | let second: serde_json::Value = resp.json(); | |
| 197 | + | assert_eq!( | |
| 198 | + | second["slug"], "unique-slug-2", | |
| 199 | + | "collision must auto-suffix to unique-slug-2: {}", second | |
| 200 | + | ); | |
| 191 | 201 | } | |
| 192 | 202 | ||
| 193 | 203 | #[tokio::test] |
| @@ -103,6 +103,9 @@ | |||
| 103 | 103 | Ok(rotation) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | /// One sync-log entry awaiting re-encryption: (seq, table_name, row_id, data). | |
| 107 | + | pub type RotationEntry = (i64, String, String, Option<JsonValue>); | |
| 108 | + | ||
| 106 | 109 | /// Pull sync log entries that need re-encryption (key_id != new_key_id). | |
| 107 | 110 | /// Returns entries ordered by seq, paginated by after_seq. | |
| 108 | 111 | #[tracing::instrument(skip_all)] | |
| @@ -113,8 +116,8 @@ | |||
| 113 | 116 | new_key_id: i32, | |
| 114 | 117 | after_seq: i64, | |
| 115 | 118 | limit: i64, | |
| 116 | - | ) -> Result<Vec<(i64, String, String, Option<JsonValue>)>> { | |
| 117 | - | let entries: Vec<(i64, String, String, Option<JsonValue>)> = sqlx::query_as( | |
| 119 | + | ) -> Result<Vec<RotationEntry>> { | |
| 120 | + | let entries: Vec<RotationEntry> = sqlx::query_as( | |
| 118 | 121 | r#" | |
| 119 | 122 | SELECT seq, table_name, row_id, data FROM sync_log | |
| 120 | 123 | WHERE app_id = $1 AND user_id = $2 |