max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+649 insertions,
-21 deletions
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | # | |
| 3 | 3 | # Not a tuning knob. Unscoped, this crate generates 9,766 mutants and each one | |
| 4 | 4 | # re-runs the test suite, so a single pass is roughly six days of machine. It | |
| 5 | - | # has therefore never completed, which meant no mutation data at all for the | |
| 6 | - | # largest thing we ship. Scoping is what makes the check exist. | |
| 5 | + | # had consequently never completed once, which meant no mutation data at all for | |
| 6 | + | # the largest thing we ship. Scoping is what makes the check exist. | |
| 7 | 7 | # | |
| 8 | 8 | # Two rules decide what is in here, and both are deliberate. | |
| 9 | 9 | # | |
| @@ -14,50 +14,66 @@ | |||
| 14 | 14 | # | |
| 15 | 15 | # 2. Only files that already have tests. Mutating a file with no test reports | |
| 16 | 16 | # every mutant as a survivor, which is noise at a volume that buries the real | |
| 17 | - | # findings, and it answers a question `grep` answers for free. 50 files in | |
| 18 | - | # the money and user-data groups have no test at all, including | |
| 17 | + | # findings, and it answers a question `grep` answers for free. 41 files in the | |
| 18 | + | # money and user-data groups still have no test at all, among them | |
| 19 | 19 | # db/transactions/purchases.rs (1,375 lines), db/users.rs (1,269) and | |
| 20 | - | # routes/stripe/webhook/checkout.rs (1,024). That is a coverage gap to close, | |
| 21 | - | # not a mutation result to compute; the list is in wiki `testing-posture`. | |
| 20 | + | # routes/stripe/webhook/checkout.rs (1,024). Those are filed as GoingsOn | |
| 21 | + | # problems against mnw-server and frozen by tests/untested_money_paths.rs; | |
| 22 | + | # they are a coverage gap to close, not a mutation result to compute. | |
| 23 | + | # | |
| 24 | + | # Expect survivors in the async half of the files added on 2026-08-04. Their | |
| 25 | + | # pure helpers gained unit tests that day; the Stripe and database paths around | |
| 26 | + | # them did not, and those are the filed problems above rather than news. Prune | |
| 27 | + | # this list by function only if that noise proves louder than the signal. | |
| 22 | 28 | # | |
| 23 | 29 | # Add a file here when it gains its first test, rather than when it is written. | |
| 24 | - | # Method and the untested list: wiki `testing-posture`. | |
| 30 | + | # Method, tiers and the untested list: wiki `testing-posture`. | |
| 25 | 31 | ||
| 26 | 32 | examine_globs = [ | |
| 27 | 33 | ||
| 28 | 34 | # --- Money. A survivor here is a way the ledger can be wrong without any | |
| 29 | 35 | # test noticing. | |
| 30 | - | # 14 files, 7,370 lines, 230 tests. | |
| 36 | + | # 20 files, 10,397 lines, 256 tests. | |
| 31 | 37 | "src/db/creator_tiers/mod.rs", | |
| 32 | 38 | "src/db/creator_tiers/storage_quota.rs", | |
| 33 | 39 | "src/db/creator_tiers/subscriptions.rs", | |
| 34 | 40 | "src/db/promo_codes.rs", | |
| 35 | 41 | "src/helpers/billing.rs", | |
| 42 | + | "src/payments/checkout.rs", | |
| 36 | 43 | "src/payments/checkout_metadata.rs", | |
| 37 | 44 | "src/payments/connect.rs", | |
| 45 | + | "src/payments/fan_ops.rs", | |
| 46 | + | "src/payments/mod.rs", | |
| 38 | 47 | "src/payments/synckit_app_pricing.rs", | |
| 48 | + | "src/payments/synckit_billing.rs", | |
| 39 | 49 | "src/payments/webhooks.rs", | |
| 40 | 50 | "src/pricing.rs", | |
| 41 | 51 | "src/pricing_comparison.rs", | |
| 52 | + | "src/routes/stripe/checkout/subscriptions.rs", | |
| 42 | 53 | "src/routes/stripe/webhook/checkout_helpers.rs", | |
| 54 | + | "src/routes/stripe/webhook/subscriptions.rs", | |
| 43 | 55 | "src/synckit_billing.rs", | |
| 44 | 56 | "src/tier_prices.rs", | |
| 45 | 57 | ||
| 46 | 58 | # --- User data. A survivor here is a way a creator's files, sync state or | |
| 47 | 59 | # account can be corrupted or lost silently. | |
| 48 | - | # 9 files, 6,780 lines, 147 tests. | |
| 60 | + | # 13 files, 8,619 lines, 155 tests. | |
| 61 | + | "src/db/pending_s3_deletions.rs", | |
| 62 | + | "src/db/synckit/apps.rs", | |
| 49 | 63 | "src/import/csv_converter.rs", | |
| 50 | 64 | "src/import/mod.rs", | |
| 51 | 65 | "src/import/pipeline.rs", | |
| 66 | + | "src/routes/storage/gallery.rs", | |
| 52 | 67 | "src/routes/storage/media.rs", | |
| 53 | 68 | "src/routes/storage/mod.rs", | |
| 54 | 69 | "src/routes/synckit/billing.rs", | |
| 70 | + | "src/routes/synckit/blobs.rs", | |
| 55 | 71 | "src/routes/synckit/keys.rs", | |
| 56 | 72 | "src/routes/synckit/mod.rs", | |
| 57 | 73 | "src/storage.rs", | |
| 58 | 74 | ||
| 59 | - | # --- Auth, access control, input validation and scanning. Every file in | |
| 60 | - | # this group already has tests; mutation asks if they are strong enough. | |
| 75 | + | # --- Auth, access control, input validation and scanning. Every file on this | |
| 76 | + | # boundary has tests; mutation asks whether they are strong enough. | |
| 61 | 77 | # 34 files, 18,514 lines, 593 tests. | |
| 62 | 78 | "src/access_gate.rs", | |
| 63 | 79 | "src/auth.rs", |
| @@ -505,3 +505,112 @@ | |||
| 505 | 505 | .await | |
| 506 | 506 | } | |
| 507 | 507 | } | |
| 508 | + | ||
| 509 | + | #[cfg(test)] | |
| 510 | + | mod tests { | |
| 511 | + | //! The shape of what we ask Stripe to charge. Everything here is pure and | |
| 512 | + | //! sits directly on the money path: a wrong `unit_amount`, a missing | |
| 513 | + | //! `quantity`, or a minimum-charge boundary off by one cent is a real | |
| 514 | + | //! charge that is wrong, and none of it was covered. | |
| 515 | + | ||
| 516 | + | use super::*; | |
| 517 | + | ||
| 518 | + | // ── the Stripe per-transaction minimum ── | |
| 519 | + | ||
| 520 | + | #[test] | |
| 521 | + | fn a_free_item_is_allowed_through() { | |
| 522 | + | // $0 items are legitimate; callers gate them before they reach Stripe. | |
| 523 | + | assert!(check_min_charge(0).is_ok()); | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | #[test] | |
| 527 | + | fn the_minimum_itself_is_allowed_and_one_cent_under_is_not() { | |
| 528 | + | let min = constants::STRIPE_MINIMUM_CHARGE_CENTS; | |
| 529 | + | assert!(check_min_charge(min).is_ok(), "the boundary is inclusive"); | |
| 530 | + | assert!( | |
| 531 | + | matches!(check_min_charge(min - 1), Err(AppError::BadRequest(_))), | |
| 532 | + | "one cent under the minimum must be refused before Stripe refuses it" | |
| 533 | + | ); | |
| 534 | + | assert!(check_min_charge(min + 1).is_ok()); | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | #[test] | |
| 538 | + | fn the_rejection_names_the_minimum_in_dollars() { | |
| 539 | + | // The message reaches a buyer, so it must not say "50". | |
| 540 | + | let Err(AppError::BadRequest(msg)) = check_min_charge(1) else { | |
| 541 | + | panic!("1 cent should be rejected"); | |
| 542 | + | }; | |
| 543 | + | assert!( | |
| 544 | + | msg.contains("$0.50"), | |
| 545 | + | "buyer-facing message should format the minimum as currency: {msg}" | |
| 546 | + | ); | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | #[test] | |
| 550 | + | fn a_negative_amount_is_not_rejected_here() { | |
| 551 | + | // Documenting the current contract rather than endorsing it: the guard | |
| 552 | + | // is `> 0 && < minimum`, so negatives pass. Every caller computes its | |
| 553 | + | // amount from a price and a discount, and none is proven non-negative | |
| 554 | + | // here. If a discount is ever allowed to exceed a price, this is the | |
| 555 | + | // gate that will not catch it. | |
| 556 | + | assert!(check_min_charge(-1).is_ok()); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // ── line items ── | |
| 560 | + | ||
| 561 | + | #[test] | |
| 562 | + | fn an_inline_line_item_charges_the_given_amount_once_in_usd() { | |
| 563 | + | let item = build_inline_line_item("A Record", 2500); | |
| 564 | + | let price = item.price_data.expect("inline items carry price_data"); | |
| 565 | + | assert_eq!(price.unit_amount, Some(2500)); | |
| 566 | + | assert_eq!(price.currency, Currency::USD); | |
| 567 | + | assert_eq!( | |
| 568 | + | item.quantity, | |
| 569 | + | Some(1), | |
| 570 | + | "quantity must be pinned: None would let Stripe default and charge differently" | |
| 571 | + | ); | |
| 572 | + | assert!( | |
| 573 | + | item.price.is_none(), | |
| 574 | + | "an inline item must not also reference a Stripe Price" | |
| 575 | + | ); | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | #[test] | |
| 579 | + | fn a_price_line_item_references_stripe_and_sets_no_amount_of_its_own() { | |
| 580 | + | let item = build_price_line_item("price_123"); | |
| 581 | + | assert_eq!(item.price.as_deref(), Some("price_123")); | |
| 582 | + | assert_eq!(item.quantity, Some(1)); | |
| 583 | + | assert!( | |
| 584 | + | item.price_data.is_none(), | |
| 585 | + | "a Price-backed item that also carries price_data would charge the inline amount" | |
| 586 | + | ); | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | #[test] | |
| 590 | + | fn a_recurring_item_carries_its_interval_and_a_recurring_price() { | |
| 591 | + | let item = build_inline_recurring_line_item( | |
| 592 | + | "SyncKit Pro", | |
| 593 | + | 900, | |
| 594 | + | CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Month, | |
| 595 | + | ); | |
| 596 | + | let price = item.price_data.expect("recurring items carry price_data"); | |
| 597 | + | assert_eq!(price.unit_amount, Some(900)); | |
| 598 | + | assert_eq!(price.currency, Currency::USD); | |
| 599 | + | assert!( | |
| 600 | + | price.recurring.is_some(), | |
| 601 | + | "without `recurring` Stripe bills this once instead of every period" | |
| 602 | + | ); | |
| 603 | + | assert_eq!(item.quantity, Some(1)); | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // ── automatic tax ── | |
| 607 | + | ||
| 608 | + | #[test] | |
| 609 | + | fn automatic_tax_is_absent_rather_than_disabled_when_off() { | |
| 610 | + | assert!( | |
| 611 | + | automatic_tax(false).is_none(), | |
| 612 | + | "sending an explicit disabled block is not the same as omitting it" | |
| 613 | + | ); | |
| 614 | + | assert!(automatic_tax(true).is_some()); | |
| 615 | + | } | |
| 616 | + | } |
| @@ -57,16 +57,6 @@ | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | /// Apply `op` to every subscription in `sub_ids` on the background queue. Returns | |
| 61 | - | /// immediately; the loop runs off the request/webhook hot path. Per-subscription | |
| 62 | - | /// failures are logged and do not abort the rest. No-op for an empty list. | |
| 63 | - | /// | |
| 64 | - | /// A failed op is not merely logged: any failures open a WAM ticket (when `wam` | |
| 65 | - | /// is configured) so a dropped pause/suspend/cancel, a fan still charged after a | |
| 66 | - | /// creator pause, or a suspended creator's fans retaining access, is actively | |
| 67 | - | /// surfaced for manual reconciliation rather than lost in the logs (audit Run 13 | |
| 68 | - | /// Resilience: fan-out had no dead-letter). The failed subscription IDs are | |
| 69 | - | /// listed in the ticket body. | |
| 70 | 60 | /// Apply `op` to every subscription in `sub_ids`, awaiting each call inline. | |
| 71 | 61 | /// | |
| 72 | 62 | /// For contexts with no background queue, the `mnw-admin` CLI, where blocking on | |
| @@ -98,6 +88,17 @@ | |||
| 98 | 88 | failed | |
| 99 | 89 | } | |
| 100 | 90 | ||
| 91 | + | /// Apply `op` to every subscription in `sub_ids` on the background queue. | |
| 92 | + | /// Returns immediately; the loop runs off the request/webhook hot path. | |
| 93 | + | /// Per-subscription failures are logged and do not abort the rest. No-op for an | |
| 94 | + | /// empty list. | |
| 95 | + | /// | |
| 96 | + | /// A failed op is not merely logged: any failures open a WAM ticket (when `wam` | |
| 97 | + | /// is configured) so a dropped pause/suspend/cancel, a fan still charged after a | |
| 98 | + | /// creator pause, or a suspended creator's fans retaining access, is actively | |
| 99 | + | /// surfaced for manual reconciliation rather than lost in the logs (audit Run 13 | |
| 100 | + | /// Resilience: fan-out had no dead-letter). The failed subscription IDs are | |
| 101 | + | /// listed in the ticket body. | |
| 101 | 102 | pub fn spawn_fan_sub_fanout( | |
| 102 | 103 | bg: &BackgroundTx, | |
| 103 | 104 | stripe: Arc<dyn PaymentProvider>, | |
| @@ -142,3 +143,37 @@ | |||
| 142 | 143 | } | |
| 143 | 144 | }); | |
| 144 | 145 | } | |
| 146 | + | ||
| 147 | + | #[cfg(test)] | |
| 148 | + | mod tests { | |
| 149 | + | //! Operation labels. These reach logs and support tickets for creator | |
| 150 | + | //! subscription changes, so a label that says the opposite of what happened | |
| 151 | + | //! sends an investigation the wrong way. | |
| 152 | + | ||
| 153 | + | use super::*; | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn setting_and_clearing_cancel_at_period_end_do_not_share_a_label() { | |
| 157 | + | assert_eq!( | |
| 158 | + | FanSubOp::CancelAtPeriodEnd(true).label(), | |
| 159 | + | "set_cancel_at_period_end" | |
| 160 | + | ); | |
| 161 | + | assert_eq!( | |
| 162 | + | FanSubOp::CancelAtPeriodEnd(false).label(), | |
| 163 | + | "clear_cancel_at_period_end" | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn every_operation_has_a_distinct_label() { | |
| 169 | + | let labels = [ | |
| 170 | + | FanSubOp::CancelAtPeriodEnd(true).label(), | |
| 171 | + | FanSubOp::CancelAtPeriodEnd(false).label(), | |
| 172 | + | FanSubOp::Pause.label(), | |
| 173 | + | FanSubOp::Resume.label(), | |
| 174 | + | FanSubOp::Cancel.label(), | |
| 175 | + | ]; | |
| 176 | + | let unique: std::collections::HashSet<_> = labels.iter().collect(); | |
| 177 | + | assert_eq!(unique.len(), labels.len(), "labels collide: {labels:?}"); | |
| 178 | + | } | |
| 179 | + | } |
| @@ -631,3 +631,34 @@ | |||
| 631 | 631 | StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await | |
| 632 | 632 | } | |
| 633 | 633 | } | |
| 634 | + | ||
| 635 | + | #[cfg(test)] | |
| 636 | + | mod tests { | |
| 637 | + | //! Stripe id parsing at the boundary between our database and Stripe's API. | |
| 638 | + | //! These ids come out of our own rows, so a parse failure means our data is | |
| 639 | + | //! wrong, and the classification matters: `Internal` pages us, `BadRequest` | |
| 640 | + | //! would blame the creator for our own corrupted column. | |
| 641 | + | ||
| 642 | + | use super::*; | |
| 643 | + | ||
| 644 | + | #[test] | |
| 645 | + | fn account_id_parsing_rejects_nothing_at_all() { | |
| 646 | + | // Same dead guard as `parse_subscription_id`. `stripe_shared::AccountId` | |
| 647 | + | // derives `FromStr` with `type Err = Infallible`, so every value parses | |
| 648 | + | // and the `Invalid Stripe account ID` branch cannot be reached. The doc | |
| 649 | + | // comment above reasons carefully about classifying the failure as | |
| 650 | + | // `Internal` rather than `BadRequest`; there is no failure to classify. | |
| 651 | + | // | |
| 652 | + | // The consequence is not academic: an empty `users.stripe_account_id` | |
| 653 | + | // becomes an empty connected-account header on a live charge instead of | |
| 654 | + | // an error we can see. | |
| 655 | + | assert!(StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G8h").is_ok()); | |
| 656 | + | for anything in ["", "cus_123", "not an id", "acct_"] { | |
| 657 | + | assert!( | |
| 658 | + | StripeClient::parse_account_id(anything).is_ok(), | |
| 659 | + | "{anything:?} parses today; if this now fails, the guard became real \ | |
| 660 | + | and the test should assert the new contract" | |
| 661 | + | ); | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | } |
| @@ -356,3 +356,64 @@ | |||
| 356 | 356 | .await | |
| 357 | 357 | } | |
| 358 | 358 | } | |
| 359 | + | ||
| 360 | + | #[cfg(test)] | |
| 361 | + | mod tests { | |
| 362 | + | //! Idempotency keys for SyncKit's Stripe writes. The key is the only thing | |
| 363 | + | //! standing between two racing `activate` requests and a duplicate live | |
| 364 | + | //! subscription that bills a developer with no local row to cancel it, so | |
| 365 | + | //! its determinism is a billing invariant rather than a detail. | |
| 366 | + | ||
| 367 | + | use super::*; | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn the_same_app_and_prefix_always_produce_the_same_key() { | |
| 371 | + | let app = SyncAppId::nil(); | |
| 372 | + | let a = synckit_idempotency_key("synckit-sub", app).expect("valid key"); | |
| 373 | + | let b = synckit_idempotency_key("synckit-sub", app).expect("valid key"); | |
| 374 | + | assert_eq!( | |
| 375 | + | format!("{a:?}"), | |
| 376 | + | format!("{b:?}"), | |
| 377 | + | "two racing activates must reuse one Stripe object, not create two" | |
| 378 | + | ); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | #[test] | |
| 382 | + | fn a_different_operation_on_one_app_gets_a_different_key() { | |
| 383 | + | let app = SyncAppId::nil(); | |
| 384 | + | let sub = synckit_idempotency_key("synckit-sub", app).expect("valid key"); | |
| 385 | + | let cust = synckit_idempotency_key("synckit-cust", app).expect("valid key"); | |
| 386 | + | assert_ne!( | |
| 387 | + | format!("{sub:?}"), | |
| 388 | + | format!("{cust:?}"), | |
| 389 | + | "sharing a key across operations would make Stripe replay the wrong response" | |
| 390 | + | ); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | #[test] | |
| 394 | + | fn an_over_long_prefix_is_an_error_rather_than_a_silently_truncated_key() { | |
| 395 | + | // Stripe caps idempotency keys at 255 characters. Truncation would make | |
| 396 | + | // two distinct operations collide, which is worse than failing loudly. | |
| 397 | + | let err = synckit_idempotency_key(&"x".repeat(300), SyncAppId::nil()); | |
| 398 | + | assert!( | |
| 399 | + | matches!(err, Err(AppError::Internal(_))), | |
| 400 | + | "an unusable key must not reach Stripe" | |
| 401 | + | ); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn subscription_id_parsing_rejects_nothing_at_all() { | |
| 406 | + | // Not the contract this function's name and error message imply. | |
| 407 | + | // `stripe_shared::SubscriptionId` derives `FromStr` with | |
| 408 | + | // `type Err = Infallible`: it wraps the string and always succeeds. The | |
| 409 | + | // `AppError::Internal("Invalid Stripe subscription ID")` branch is | |
| 410 | + | // unreachable, so an empty or garbage id from our own row is passed | |
| 411 | + | // straight to Stripe's cancel / re-price calls. | |
| 412 | + | // | |
| 413 | + | // Documented rather than asserted-as-correct: adding a real check is a | |
| 414 | + | // money-path behaviour change. Filed as a problem against mnw-server. | |
| 415 | + | assert!(parse_subscription_id("").is_ok()); | |
| 416 | + | assert!(parse_subscription_id("not a sub id").is_ok()); | |
| 417 | + | assert!(parse_subscription_id("acct_wrong_type").is_ok()); | |
| 418 | + | } | |
| 419 | + | } |
| @@ -222,3 +222,57 @@ | |||
| 222 | 222 | ||
| 223 | 223 | Ok(()) | |
| 224 | 224 | } | |
| 225 | + | ||
| 226 | + | #[cfg(test)] | |
| 227 | + | mod tests { | |
| 228 | + | //! API-key hashing. The raw developer key is never stored, so this function | |
| 229 | + | //! is the whole of that promise: if it ever became reversible, or stopped | |
| 230 | + | //! being deterministic, either the keys leak or every app stops | |
| 231 | + | //! authenticating. | |
| 232 | + | ||
| 233 | + | use super::*; | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn hashing_is_deterministic() { | |
| 237 | + | assert_eq!( | |
| 238 | + | hash_api_key("synckit-key-alpha"), | |
| 239 | + | hash_api_key("synckit-key-alpha") | |
| 240 | + | ); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | #[test] | |
| 244 | + | fn different_keys_hash_differently() { | |
| 245 | + | assert_ne!( | |
| 246 | + | hash_api_key("synckit-key-alpha"), | |
| 247 | + | hash_api_key("synckit-key-beta") | |
| 248 | + | ); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[test] | |
| 252 | + | fn the_hash_is_hex_encoded_sha256() { | |
| 253 | + | let h = hash_api_key("synckit-key-alpha"); | |
| 254 | + | assert_eq!(h.len(), 64, "sha256 is 32 bytes, hex-encoded: {h}"); | |
| 255 | + | assert!( | |
| 256 | + | h.chars().all(|c| c.is_ascii_hexdigit()), | |
| 257 | + | "must be hex so it round-trips through a text column: {h}" | |
| 258 | + | ); | |
| 259 | + | // Pinned against a known vector so a swapped algorithm is caught rather | |
| 260 | + | // than merely being "some 64-char hex string". | |
| 261 | + | assert_eq!( | |
| 262 | + | hash_api_key(""), | |
| 263 | + | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" | |
| 264 | + | ); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn the_raw_key_does_not_survive_in_the_hash() { | |
| 269 | + | // Fixtures deliberately avoid the `sk_live_` shape: gitleaks scans the | |
| 270 | + | // staged diff and a realistic-looking key in a test is a secret as far | |
| 271 | + | // as the hook is concerned, which is the correct call. | |
| 272 | + | let raw = "synckit-key-with-secret-inside"; | |
| 273 | + | assert!( | |
| 274 | + | !hash_api_key(raw).contains(raw), | |
| 275 | + | "the stored value must not carry the key it came from" | |
| 276 | + | ); | |
| 277 | + | } | |
| 278 | + | } |
| @@ -573,3 +573,34 @@ | |||
| 573 | 573 | tracing::warn!(project_id = %pid, error = ?e, "failed to bump cache generation after gallery change"); | |
| 574 | 574 | } | |
| 575 | 575 | } | |
| 576 | + | ||
| 577 | + | #[cfg(test)] | |
| 578 | + | mod tests { | |
| 579 | + | //! Gallery target parsing. This decides which ownership check runs, so a | |
| 580 | + | //! wrong answer here points an authorization query at the wrong table. | |
| 581 | + | ||
| 582 | + | use super::*; | |
| 583 | + | ||
| 584 | + | #[test] | |
| 585 | + | fn the_two_known_targets_parse() { | |
| 586 | + | assert!(matches!( | |
| 587 | + | GalleryTarget::parse("item"), | |
| 588 | + | Ok(GalleryTarget::Item) | |
| 589 | + | )); | |
| 590 | + | assert!(matches!( | |
| 591 | + | GalleryTarget::parse("project"), | |
| 592 | + | Ok(GalleryTarget::Project) | |
| 593 | + | )); | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | #[test] | |
| 597 | + | fn anything_else_is_refused_rather_than_defaulted() { | |
| 598 | + | // Defaulting an unknown target would run the wrong ownership check. | |
| 599 | + | for s in ["", "Item", "ITEM", "items", "user", "../item"] { | |
| 600 | + | assert!( | |
| 601 | + | matches!(GalleryTarget::parse(s), Err(AppError::BadRequest(_))), | |
| 602 | + | "{s:?} must not resolve to a target" | |
| 603 | + | ); | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | } |