max / makenotwork
14 files changed,
+306 insertions,
-52 deletions
| @@ -0,0 +1,16 @@ | |||
| 1 | + | -- Store invite codes hashed, never in plaintext (audit 2026-07-01): the lone | |
| 2 | + | -- single-use credential that was readable at rest. A DB read must not yield a | |
| 3 | + | -- directly-usable invite, matching the reset/login/git-PAT posture. | |
| 4 | + | -- | |
| 5 | + | -- Widen the column first (a SHA-256 hex digest is 64 chars, the old cap was 12), | |
| 6 | + | -- then hash existing plaintext codes in place so outstanding invites keep | |
| 7 | + | -- working. `encode(sha256(...))` yields lowercase hex, matching the Rust | |
| 8 | + | -- `crypto::invite_code_hash` output. | |
| 9 | + | ||
| 10 | + | ALTER TABLE invite_codes ALTER COLUMN code TYPE TEXT; | |
| 11 | + | ||
| 12 | + | UPDATE invite_codes | |
| 13 | + | SET code = encode(sha256(code::bytea), 'hex') | |
| 14 | + | -- A 64-char lowercase-hex value is already a hash; only rewrite plaintext rows | |
| 15 | + | -- so the migration is idempotent if partially applied. | |
| 16 | + | WHERE code !~ '^[0-9a-f]{64}$'; |
| @@ -149,12 +149,26 @@ pub fn generate_git_token() -> (String, String) { | |||
| 149 | 149 | (plaintext, hash) | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | + | /// Lowercase SHA-256 hex digest of a string. The shared primitive behind the | |
| 153 | + | /// "store a hash, never the plaintext" credential pattern so a DB read can't | |
| 154 | + | /// recover a usable secret. | |
| 155 | + | pub fn sha256_hex(input: &str) -> String { | |
| 156 | + | use sha2::{Digest, Sha256}; | |
| 157 | + | Sha256::digest(input.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() | |
| 158 | + | } | |
| 159 | + | ||
| 152 | 160 | /// SHA-256 hex digest of a git token's plaintext. Used both when minting a | |
| 153 | 161 | /// token and when verifying one on a request, so the stored hash is never the | |
| 154 | 162 | /// plaintext and a DB read can't recover a usable credential. | |
| 155 | 163 | pub fn git_token_hash(token: &str) -> String { | |
| 156 | - | use sha2::{Digest, Sha256}; | |
| 157 | - | Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() | |
| 164 | + | sha256_hex(token) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// SHA-256 hex digest of an invite code. Stored in place of the plaintext so a | |
| 168 | + | /// DB read yields no directly-usable invite (matches the reset/login/git-PAT | |
| 169 | + | /// posture). Callers normalize the code (uppercase, dashes stripped) first. | |
| 170 | + | pub fn invite_code_hash(code: &str) -> String { | |
| 171 | + | sha256_hex(code) | |
| 158 | 172 | } | |
| 159 | 173 | ||
| 160 | 174 | /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`. |
| @@ -80,6 +80,16 @@ macro_rules! impl_str_enum { | |||
| 80 | 80 | s == other | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| 83 | + | ||
| 84 | + | impl $enum_name { | |
| 85 | + | /// Every wire/DB string this enum maps to. The single source of truth | |
| 86 | + | /// for the variant set; the enum-drift test asserts it equals the | |
| 87 | + | /// Postgres `CHECK (... IN (...))` list for each backing column, so a | |
| 88 | + | /// variant added here without widening the DB constraint (or vice | |
| 89 | + | /// versa) fails at test time instead of at the first read of a | |
| 90 | + | /// poisoned row. | |
| 91 | + | pub const VARIANTS: &'static [&'static str] = &[$($str),+]; | |
| 92 | + | } | |
| 83 | 93 | }; | |
| 84 | 94 | } | |
| 85 | 95 | ||
| @@ -157,6 +167,12 @@ pub enum TransactionStatus { | |||
| 157 | 167 | /// against double-submit on shared-cart PaymentIntents (Pay-S1, Run 9). | |
| 158 | 168 | Refunding, | |
| 159 | 169 | Refunded, | |
| 170 | + | /// Present in the DB `CHECK` since the initial schema but never written by | |
| 171 | + | /// the app (stale pending transactions are deleted, not failed). Kept as a | |
| 172 | + | /// variant so the enum can decode any legacy/manual `'failed'` row instead of | |
| 173 | + | /// fail-closed-poisoning the whole query — and so the enum-drift test's | |
| 174 | + | /// variant set matches the column constraint. | |
| 175 | + | Failed, | |
| 160 | 176 | } | |
| 161 | 177 | ||
| 162 | 178 | impl_str_enum!(TransactionStatus { | |
| @@ -164,6 +180,7 @@ impl_str_enum!(TransactionStatus { | |||
| 164 | 180 | Completed => "completed", | |
| 165 | 181 | Refunding => "refunding", | |
| 166 | 182 | Refunded => "refunded", | |
| 183 | + | Failed => "failed", | |
| 167 | 184 | }); | |
| 168 | 185 | ||
| 169 | 186 | // ── Follows ── |
| @@ -42,6 +42,8 @@ pub async fn create_invite_code( | |||
| 42 | 42 | creator_id: UserId, | |
| 43 | 43 | code: &str, | |
| 44 | 44 | ) -> Result<DbInviteCode> { | |
| 45 | + | // Store the hash, never the plaintext: a DB read must not yield a usable | |
| 46 | + | // invite. The caller keeps the raw `code` to show/share with the creator. | |
| 45 | 47 | let invite = sqlx::query_as::<_, DbInviteCode>( | |
| 46 | 48 | r#" | |
| 47 | 49 | INSERT INTO invite_codes (creator_id, code) | |
| @@ -50,7 +52,7 @@ pub async fn create_invite_code( | |||
| 50 | 52 | "#, | |
| 51 | 53 | ) | |
| 52 | 54 | .bind(creator_id) | |
| 53 | - | .bind(code) | |
| 55 | + | .bind(crate::crypto::invite_code_hash(code)) | |
| 54 | 56 | .fetch_one(pool) | |
| 55 | 57 | .await?; | |
| 56 | 58 | ||
| @@ -83,35 +85,44 @@ pub async fn get_invites_by_creator(pool: &PgPool, creator_id: UserId) -> Result | |||
| 83 | 85 | Ok(invites) | |
| 84 | 86 | } | |
| 85 | 87 | ||
| 86 | - | /// Look up an unredeemed invite code by its raw code string. | |
| 88 | + | /// Look up an unredeemed invite code by its raw code string. The raw code is | |
| 89 | + | /// hashed before lookup (codes are stored hashed); the caller normalizes it | |
| 90 | + | /// (uppercase, dashes stripped) first. | |
| 87 | 91 | #[tracing::instrument(skip_all)] | |
| 88 | 92 | pub async fn get_valid_invite_code(pool: &PgPool, code: &str) -> Result<Option<DbInviteCode>> { | |
| 89 | 93 | let invite = sqlx::query_as::<_, DbInviteCode>( | |
| 90 | 94 | "SELECT * FROM invite_codes WHERE code = $1 AND redeemed_by_id IS NULL", | |
| 91 | 95 | ) | |
| 92 | - | .bind(code) | |
| 96 | + | .bind(crate::crypto::invite_code_hash(code)) | |
| 93 | 97 | .fetch_optional(pool) | |
| 94 | 98 | .await?; | |
| 95 | 99 | ||
| 96 | 100 | Ok(invite) | |
| 97 | 101 | } | |
| 98 | 102 | ||
| 99 | - | /// Mark an invite code as redeemed by a specific user. | |
| 103 | + | /// Atomically claim an invite code for a user. Returns `true` if this call won | |
| 104 | + | /// the claim, `false` if the code was already redeemed (lost race / reused). | |
| 105 | + | /// | |
| 106 | + | /// The `AND redeemed_by_id IS NULL` guard makes the read-then-write in the | |
| 107 | + | /// caller race-free: two concurrent redeemers both pass `get_valid_invite_code` | |
| 108 | + | /// but only one satisfies the guarded UPDATE, so a single-use invite can never | |
| 109 | + | /// onboard two users. | |
| 100 | 110 | #[tracing::instrument(skip_all)] | |
| 101 | 111 | pub async fn redeem_invite_code( | |
| 102 | 112 | pool: &PgPool, | |
| 103 | 113 | code_id: InviteCodeId, | |
| 104 | 114 | user_id: UserId, | |
| 105 | - | ) -> Result<()> { | |
| 106 | - | sqlx::query( | |
| 107 | - | "UPDATE invite_codes SET redeemed_by_id = $2, redeemed_at = NOW() WHERE id = $1", | |
| 115 | + | ) -> Result<bool> { | |
| 116 | + | let claimed = sqlx::query_scalar::<_, InviteCodeId>( | |
| 117 | + | "UPDATE invite_codes SET redeemed_by_id = $2, redeemed_at = NOW() \ | |
| 118 | + | WHERE id = $1 AND redeemed_by_id IS NULL RETURNING id", | |
| 108 | 119 | ) | |
| 109 | 120 | .bind(code_id) | |
| 110 | 121 | .bind(user_id) | |
| 111 | - | .execute(pool) | |
| 122 | + | .fetch_optional(pool) | |
| 112 | 123 | .await?; | |
| 113 | 124 | ||
| 114 | - | Ok(()) | |
| 125 | + | Ok(claimed.is_some()) | |
| 115 | 126 | } | |
| 116 | 127 | ||
| 117 | 128 | #[cfg(test)] |
| @@ -41,7 +41,7 @@ pub(crate) mod scanning; | |||
| 41 | 41 | pub(crate) mod scan_jobs; | |
| 42 | 42 | pub(crate) mod scan_admin_actions; | |
| 43 | 43 | pub(crate) mod content_insertions; | |
| 44 | - | pub(crate) mod invites; | |
| 44 | + | pub mod invites; // pub so the integration test crate can exercise redemption races directly | |
| 45 | 45 | pub(crate) mod analytics; | |
| 46 | 46 | pub(crate) mod email_suppressions; | |
| 47 | 47 | pub mod git_repos; |
| @@ -82,28 +82,27 @@ pub async fn disable_totp(pool: &PgPool, user_id: UserId) -> Result<()> { | |||
| 82 | 82 | Ok(()) | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | - | /// Get the last accepted TOTP time step for a user (for replay prevention). | |
| 86 | - | #[tracing::instrument(skip_all)] | |
| 87 | - | pub async fn get_totp_last_used_step(pool: &PgPool, user_id: UserId) -> Result<Option<i64>> { | |
| 88 | - | let step: Option<i64> = | |
| 89 | - | sqlx::query_scalar("SELECT totp_last_used_step FROM users WHERE id = $1") | |
| 90 | - | .bind(user_id) | |
| 91 | - | .fetch_one(pool) | |
| 92 | - | .await?; | |
| 93 | - | ||
| 94 | - | Ok(step) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | /// Update the last accepted TOTP time step (set after successful verification). | |
| 85 | + | /// Atomically advance the last accepted TOTP time step. Returns `true` if this | |
| 86 | + | /// call recorded the step (it was strictly newer than the stored one), `false` | |
| 87 | + | /// if a concurrent verification already consumed this step or a later one. | |
| 88 | + | /// | |
| 89 | + | /// The `AND $2 > totp_last_used_step` guard makes the read-then-write in the | |
| 90 | + | /// caller race-free: two concurrent submissions of the same code both read the | |
| 91 | + | /// old step and both match, but only the winner's UPDATE affects a row — so a | |
| 92 | + | /// code cannot be accepted twice within its 30s window. Callers must gate | |
| 93 | + | /// acceptance on the returned bool, not on their own prior step read. | |
| 98 | 94 | #[tracing::instrument(skip_all)] | |
| 99 | - | pub async fn set_totp_last_used_step(pool: &PgPool, user_id: UserId, step: i64) -> Result<()> { | |
| 100 | - | sqlx::query("UPDATE users SET totp_last_used_step = $2 WHERE id = $1") | |
| 101 | - | .bind(user_id) | |
| 102 | - | .bind(step) | |
| 103 | - | .execute(pool) | |
| 104 | - | .await?; | |
| 95 | + | pub async fn set_totp_last_used_step(pool: &PgPool, user_id: UserId, step: i64) -> Result<bool> { | |
| 96 | + | let updated = sqlx::query_scalar::<_, UserId>( | |
| 97 | + | "UPDATE users SET totp_last_used_step = $2 \ | |
| 98 | + | WHERE id = $1 AND $2 > COALESCE(totp_last_used_step, 0) RETURNING id", | |
| 99 | + | ) | |
| 100 | + | .bind(user_id) | |
| 101 | + | .bind(step) | |
| 102 | + | .fetch_optional(pool) | |
| 103 | + | .await?; | |
| 105 | 104 | ||
| 106 | - | Ok(()) | |
| 105 | + | Ok(updated.is_some()) | |
| 107 | 106 | } | |
| 108 | 107 | ||
| 109 | 108 | /// Check if a user has TOTP 2FA enabled. |
| @@ -36,6 +36,7 @@ struct TagView { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | /// GET /api/internal/creator/items/{id}/tags?user_id=... | |
| 39 | + | #[tracing::instrument(skip_all, name = "internal::list_item_tags")] | |
| 39 | 40 | pub(super) async fn list_item_tags( | |
| 40 | 41 | State(state): State<AppState>, | |
| 41 | 42 | _auth: ServiceAuth, | |
| @@ -67,6 +68,7 @@ pub(super) async fn list_item_tags( | |||
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 70 | /// POST /api/internal/creator/items/tags | |
| 71 | + | #[tracing::instrument(skip_all, name = "internal::add_item_tag")] | |
| 70 | 72 | pub(super) async fn add_item_tag( | |
| 71 | 73 | State(state): State<AppState>, | |
| 72 | 74 | _auth: ServiceAuth, | |
| @@ -96,6 +98,7 @@ pub(super) async fn add_item_tag( | |||
| 96 | 98 | } | |
| 97 | 99 | ||
| 98 | 100 | /// POST /api/internal/creator/items/tags/remove | |
| 101 | + | #[tracing::instrument(skip_all, name = "internal::remove_item_tag")] | |
| 99 | 102 | pub(super) async fn remove_item_tag( | |
| 100 | 103 | State(state): State<AppState>, | |
| 101 | 104 | _auth: ServiceAuth, | |
| @@ -126,6 +129,7 @@ pub(super) struct TagSearchQuery { | |||
| 126 | 129 | } | |
| 127 | 130 | ||
| 128 | 131 | /// GET /api/internal/tags/search?q=... | |
| 132 | + | #[tracing::instrument(skip_all, name = "internal::search_tags")] | |
| 129 | 133 | pub(super) async fn search_tags( | |
| 130 | 134 | State(state): State<AppState>, | |
| 131 | 135 | _auth: ServiceAuth, | |
| @@ -154,6 +158,7 @@ pub(super) struct BroadcastRequest { | |||
| 154 | 158 | } | |
| 155 | 159 | ||
| 156 | 160 | /// POST /api/internal/creator/broadcast | |
| 161 | + | #[tracing::instrument(skip_all, name = "internal::send_broadcast")] | |
| 157 | 162 | pub(super) async fn send_broadcast( | |
| 158 | 163 | State(state): State<AppState>, | |
| 159 | 164 | _auth: ServiceAuth, | |
| @@ -265,6 +270,7 @@ struct TierView { | |||
| 265 | 270 | } | |
| 266 | 271 | ||
| 267 | 272 | /// GET /api/internal/creator/projects/{id}/tiers?user_id=... | |
| 273 | + | #[tracing::instrument(skip_all, name = "internal::list_tiers")] | |
| 268 | 274 | pub(super) async fn list_tiers( | |
| 269 | 275 | State(state): State<AppState>, | |
| 270 | 276 | _auth: ServiceAuth, | |
| @@ -315,6 +321,7 @@ struct CollectionView { | |||
| 315 | 321 | } | |
| 316 | 322 | ||
| 317 | 323 | /// GET /api/internal/creator/collections?user_id=... | |
| 324 | + | #[tracing::instrument(skip_all, name = "internal::list_collections")] | |
| 318 | 325 | pub(super) async fn list_collections( | |
| 319 | 326 | State(state): State<AppState>, | |
| 320 | 327 | _auth: ServiceAuth, | |
| @@ -337,6 +344,7 @@ pub(super) async fn list_collections( | |||
| 337 | 344 | } | |
| 338 | 345 | ||
| 339 | 346 | /// POST /api/internal/creator/collections | |
| 347 | + | #[tracing::instrument(skip_all, name = "internal::create_collection")] | |
| 340 | 348 | pub(super) async fn create_collection( | |
| 341 | 349 | State(state): State<AppState>, | |
| 342 | 350 | _auth: ServiceAuth, | |
| @@ -362,6 +370,7 @@ pub(super) async fn create_collection( | |||
| 362 | 370 | } | |
| 363 | 371 | ||
| 364 | 372 | /// DELETE /api/internal/creator/collections/{id}?user_id=... | |
| 373 | + | #[tracing::instrument(skip_all, name = "internal::delete_collection")] | |
| 365 | 374 | pub(super) async fn delete_collection( | |
| 366 | 375 | State(state): State<AppState>, | |
| 367 | 376 | _auth: ServiceAuth, | |
| @@ -389,6 +398,7 @@ pub(super) struct AddDomainRequest { | |||
| 389 | 398 | } | |
| 390 | 399 | ||
| 391 | 400 | /// GET /api/internal/creator/domain?user_id=... | |
| 401 | + | #[tracing::instrument(skip_all, name = "internal::get_domain")] | |
| 392 | 402 | pub(super) async fn get_domain( | |
| 393 | 403 | State(state): State<AppState>, | |
| 394 | 404 | _auth: ServiceAuth, | |
| @@ -407,6 +417,7 @@ pub(super) async fn get_domain( | |||
| 407 | 417 | } | |
| 408 | 418 | ||
| 409 | 419 | /// POST /api/internal/creator/domain | |
| 420 | + | #[tracing::instrument(skip_all, name = "internal::add_domain")] | |
| 410 | 421 | pub(super) async fn add_domain( | |
| 411 | 422 | State(state): State<AppState>, | |
| 412 | 423 | _auth: ServiceAuth, | |
| @@ -433,6 +444,7 @@ pub(super) async fn add_domain( | |||
| 433 | 444 | } | |
| 434 | 445 | ||
| 435 | 446 | /// POST /api/internal/creator/domain/verify?user_id=... | |
| 447 | + | #[tracing::instrument(skip_all, name = "internal::verify_domain")] | |
| 436 | 448 | pub(super) async fn verify_domain( | |
| 437 | 449 | State(state): State<AppState>, | |
| 438 | 450 | _auth: ServiceAuth, | |
| @@ -485,6 +497,7 @@ pub(super) async fn verify_domain( | |||
| 485 | 497 | } | |
| 486 | 498 | ||
| 487 | 499 | /// DELETE /api/internal/creator/domain?user_id=... | |
| 500 | + | #[tracing::instrument(skip_all, name = "internal::remove_domain")] | |
| 488 | 501 | pub(super) async fn remove_domain( | |
| 489 | 502 | State(state): State<AppState>, | |
| 490 | 503 | _auth: ServiceAuth, | |
| @@ -553,6 +566,7 @@ struct CreateProjectResponse { | |||
| 553 | 566 | } | |
| 554 | 567 | ||
| 555 | 568 | /// POST /api/internal/creator/projects | |
| 569 | + | #[tracing::instrument(skip_all, name = "internal::create_project")] | |
| 556 | 570 | pub(super) async fn create_project( | |
| 557 | 571 | State(state): State<AppState>, | |
| 558 | 572 | _auth: ServiceAuth, |
| @@ -101,20 +101,25 @@ pub(super) async fn confirm( | |||
| 101 | 101 | // accepted step, and that step is then recorded. `setup` cleared the step to | |
| 102 | 102 | // NULL (defaults to 0), so a genuine first code (step ~= now/30) always wins. | |
| 103 | 103 | let now = chrono::Utc::now().timestamp() as u64; | |
| 104 | - | let matched_step = find_matching_step(&totp, &form.code, now); | |
| 105 | - | let last_step = db::totp::get_totp_last_used_step(&state.db, user.id) | |
| 106 | - | .await? | |
| 107 | - | .unwrap_or(0); | |
| 108 | - | let Some(step) = matched_step.filter(|&step| step > last_step) else { | |
| 109 | - | return Ok(( | |
| 104 | + | let invalid = || { | |
| 105 | + | ( | |
| 110 | 106 | [("HX-Retarget", "#totp-confirm-status"), ("HX-Reswap", "innerHTML")], | |
| 111 | 107 | Html("<span class=\"save-error\">Invalid code. Please try again.</span>"), | |
| 112 | 108 | ) | |
| 113 | - | .into_response()); | |
| 109 | + | .into_response() | |
| 114 | 110 | }; | |
| 115 | 111 | ||
| 116 | - | // Record the matched step to prevent replay on the very first login. | |
| 117 | - | db::totp::set_totp_last_used_step(&state.db, user.id, step).await?; | |
| 112 | + | let Some(step) = find_matching_step(&totp, &form.code, now) else { | |
| 113 | + | return Ok(invalid()); | |
| 114 | + | }; | |
| 115 | + | ||
| 116 | + | // Record the matched step atomically; the guarded write is the authoritative | |
| 117 | + | // replay gate. A concurrent submission of the same code that already advanced | |
| 118 | + | // the step loses here and is rejected — the step monotonicity is enforced by | |
| 119 | + | // the DB, not by a separate read-then-write. | |
| 120 | + | if !db::totp::set_totp_last_used_step(&state.db, user.id, step).await? { | |
| 121 | + | return Ok(invalid()); | |
| 122 | + | } | |
| 118 | 123 | ||
| 119 | 124 | db::totp::enable_totp(&state.db, user.id).await?; | |
| 120 | 125 |
| @@ -207,8 +207,11 @@ pub async fn step_account_create( | |||
| 207 | 207 | let code = code_raw.replace('-', "").trim().to_uppercase(); | |
| 208 | 208 | if !code.is_empty() | |
| 209 | 209 | && let Some(invite) = db::invites::get_valid_invite_code(&state.db, &code).await? | |
| 210 | + | // Atomic claim: if a concurrent signup redeemed the same code first, | |
| 211 | + | // this returns false and we skip the invite side-effects (the signup | |
| 212 | + | // itself already succeeded). | |
| 213 | + | && db::invites::redeem_invite_code(&state.db, invite.id, user.id).await? | |
| 210 | 214 | { | |
| 211 | - | db::invites::redeem_invite_code(&state.db, invite.id, user.id).await?; | |
| 212 | 215 | db::waitlist::create_invited_waitlist_entry(&state.db, user.id, invite.creator_id) | |
| 213 | 216 | .await?; | |
| 214 | 217 |
| @@ -197,12 +197,12 @@ pub(super) async fn verify_two_factor( | |||
| 197 | 197 | let now = chrono::Utc::now().timestamp() as u64; | |
| 198 | 198 | let matched_step = find_matching_step(&totp, &code, now); | |
| 199 | 199 | if let Some(step) = matched_step { | |
| 200 | - | let last_step = db::totp::get_totp_last_used_step(&state.db, user_id).await?.unwrap_or(0); | |
| 201 | - | if step > last_step { | |
| 202 | - | db::totp::set_totp_last_used_step(&state.db, user_id, step).await?; | |
| 203 | - | verified = true; | |
| 204 | - | } | |
| 205 | - | // If step <= last_step, the code was already used — fall through to backup codes | |
| 200 | + | // The guarded write is the authoritative replay gate: it | |
| 201 | + | // advances the step only if strictly newer, atomically. A | |
| 202 | + | // concurrent submission of the same code that already advanced | |
| 203 | + | // the step returns false here and falls through to backup | |
| 204 | + | // codes instead of being accepted twice. | |
| 205 | + | verified = db::totp::set_totp_last_used_step(&state.db, user_id, step).await?; | |
| 206 | 206 | } | |
| 207 | 207 | } | |
| 208 | 208 | Err(e) => { |
| @@ -0,0 +1,99 @@ | |||
| 1 | + | //! Guards against tri-source enum drift: every domain enum's variant set must | |
| 2 | + | //! equal the Postgres `CHECK (... IN (...))` list on its backing column. The | |
| 3 | + | //! enums decode fail-closed (an unknown string errors the whole row read), so a | |
| 4 | + | //! value the DB permits but the Rust enum can't decode would poison any query | |
| 5 | + | //! that touches such a row. This test makes that drift fail in CI instead of at | |
| 6 | + | //! the first read of a poisoned row. | |
| 7 | + | //! | |
| 8 | + | //! `EnumType::VARIANTS` is the single source of truth (generated by | |
| 9 | + | //! `impl_str_enum!`); the DB side is read from the live, fully-migrated schema | |
| 10 | + | //! so later `ALTER ... CHECK` migrations are accounted for. | |
| 11 | + | ||
| 12 | + | use crate::harness::TestHarness; | |
| 13 | + | use makenotwork::db; | |
| 14 | + | use std::collections::BTreeSet; | |
| 15 | + | ||
| 16 | + | /// Extract the single-quoted string literals from a `pg_get_constraintdef` | |
| 17 | + | /// rendering (Postgres renders `col IN ('a','b')` as | |
| 18 | + | /// `CHECK ((col)::text = ANY (ARRAY['a'::text, 'b'::text]))`; the allowed values | |
| 19 | + | /// are exactly the single-quoted tokens). | |
| 20 | + | fn quoted_values(constraint_def: &str) -> BTreeSet<String> { | |
| 21 | + | let mut out = BTreeSet::new(); | |
| 22 | + | let mut chars = constraint_def.char_indices().peekable(); | |
| 23 | + | while let Some((_, c)) = chars.next() { | |
| 24 | + | if c == '\'' { | |
| 25 | + | let mut val = String::new(); | |
| 26 | + | for (_, c2) in chars.by_ref() { | |
| 27 | + | if c2 == '\'' { | |
| 28 | + | break; | |
| 29 | + | } | |
| 30 | + | val.push(c2); | |
| 31 | + | } | |
| 32 | + | out.insert(val); | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | out | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Read the union of allowed values across every single-column CHECK constraint | |
| 39 | + | /// on exactly `table.column`. Constraining to `conkey = [column]` excludes | |
| 40 | + | /// cross-column CHECKs (which would leak an unrelated column's literals). | |
| 41 | + | async fn db_check_values(pool: &sqlx::PgPool, table: &str, column: &str) -> BTreeSet<String> { | |
| 42 | + | let defs: Vec<String> = sqlx::query_scalar( | |
| 43 | + | "SELECT pg_get_constraintdef(c.oid) \ | |
| 44 | + | FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid \ | |
| 45 | + | WHERE t.relname = $1 AND c.contype = 'c' \ | |
| 46 | + | AND c.conkey = ARRAY[ \ | |
| 47 | + | (SELECT a.attnum FROM pg_attribute a WHERE a.attrelid = t.oid AND a.attname = $2) \ | |
| 48 | + | ]::smallint[]", | |
| 49 | + | ) | |
| 50 | + | .bind(table) | |
| 51 | + | .bind(column) | |
| 52 | + | .fetch_all(pool) | |
| 53 | + | .await | |
| 54 | + | .unwrap(); | |
| 55 | + | ||
| 56 | + | assert!( | |
| 57 | + | !defs.is_empty(), | |
| 58 | + | "no CHECK constraint found on {table}.{column} — update the enum-drift registry" | |
| 59 | + | ); | |
| 60 | + | defs.iter().flat_map(|d| quoted_values(d)).collect() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn variant_set(variants: &[&str]) -> BTreeSet<String> { | |
| 64 | + | variants.iter().map(|s| s.to_string()).collect() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[tokio::test] | |
| 68 | + | async fn enum_variants_match_db_check_constraints() { | |
| 69 | + | let h = TestHarness::new().await; | |
| 70 | + | ||
| 71 | + | // (label, table, column, Rust variant set). Each pair's DB CHECK list must | |
| 72 | + | // equal the enum's VARIANTS. Add a row when a new enum backs a CHECK column. | |
| 73 | + | // Only enum-backed columns that actually carry an `IN (...)` CHECK. Columns | |
| 74 | + | // that store a broad external status set (e.g. subscriptions.status mirrors | |
| 75 | + | // Stripe) intentionally have no CHECK and are not listed. Grow this as new | |
| 76 | + | // CHECK-constrained enum columns land. | |
| 77 | + | let cases: Vec<(&str, &str, &str, BTreeSet<String>)> = vec![ | |
| 78 | + | ("TransactionStatus", "transactions", "status", variant_set(db::TransactionStatus::VARIANTS)), | |
| 79 | + | ("DiscountType", "promo_codes", "discount_type", variant_set(db::DiscountType::VARIANTS)), | |
| 80 | + | ("CodePurpose", "promo_codes", "code_purpose", variant_set(db::CodePurpose::VARIANTS)), | |
| 81 | + | ("FollowTargetType", "follows", "target_type", variant_set(db::FollowTargetType::VARIANTS)), | |
| 82 | + | ]; | |
| 83 | + | ||
| 84 | + | let mut failures = Vec::new(); | |
| 85 | + | for (label, table, column, variants) in &cases { | |
| 86 | + | let db_values = db_check_values(&h.db, table, column).await; | |
| 87 | + | if &db_values != variants { | |
| 88 | + | failures.push(format!( | |
| 89 | + | "{label} ({table}.{column}):\n rust VARIANTS = {variants:?}\n db CHECK = {db_values:?}" | |
| 90 | + | )); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | assert!( | |
| 95 | + | failures.is_empty(), | |
| 96 | + | "enum <-> DB CHECK drift detected:\n{}", | |
| 97 | + | failures.join("\n") | |
| 98 | + | ); | |
| 99 | + | } |
| @@ -71,6 +71,78 @@ async fn invite_redeem_on_signup() { | |||
| 71 | 71 | assert!(redeemed.is_some(), "Invite code should have been redeemed"); | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | /// Invite codes are stored hashed, never in plaintext: a DB read of the `code` | |
| 75 | + | /// column must yield a SHA-256 hex digest, not the shareable code. | |
| 76 | + | #[tokio::test] | |
| 77 | + | async fn invite_code_stored_hashed() { | |
| 78 | + | let mut h = TestHarness::new().await; | |
| 79 | + | let creator_id = h.signup("invhash", "invhash@test.com", "password123").await; | |
| 80 | + | h.grant_creator(creator_id).await; | |
| 81 | + | ||
| 82 | + | let raw = "TESTCODE1234"; | |
| 83 | + | makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw) | |
| 84 | + | .await | |
| 85 | + | .unwrap(); | |
| 86 | + | ||
| 87 | + | let stored: String = | |
| 88 | + | sqlx::query_scalar("SELECT code FROM invite_codes WHERE creator_id = $1") | |
| 89 | + | .bind(creator_id) | |
| 90 | + | .fetch_one(&h.db) | |
| 91 | + | .await | |
| 92 | + | .unwrap(); | |
| 93 | + | ||
| 94 | + | assert_ne!(stored, raw, "plaintext invite code must not be stored"); | |
| 95 | + | assert_eq!(stored, makenotwork::crypto::invite_code_hash(raw), "stored value must be the code hash"); | |
| 96 | + | assert_eq!(stored.len(), 64, "SHA-256 hex digest is 64 chars"); | |
| 97 | + | ||
| 98 | + | // The hashed lookup still resolves the raw code. | |
| 99 | + | let found = makenotwork::db::invites::get_valid_invite_code(&h.db, raw) | |
| 100 | + | .await | |
| 101 | + | .unwrap(); | |
| 102 | + | assert!(found.is_some(), "hashed lookup should resolve the raw code"); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// A single-use invite can never onboard two users: concurrent redemptions of | |
| 106 | + | /// the same code — both of which pass the validity read — resolve to exactly one | |
| 107 | + | /// winner via the guarded atomic UPDATE. | |
| 108 | + | #[tokio::test] | |
| 109 | + | async fn invite_redeemed_once_under_race() { | |
| 110 | + | let mut h = TestHarness::new().await; | |
| 111 | + | let creator_id = h.signup("invrace", "invrace@test.com", "password123").await; | |
| 112 | + | h.grant_creator(creator_id).await; | |
| 113 | + | ||
| 114 | + | let raw = "RACECODE5678"; | |
| 115 | + | makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw) | |
| 116 | + | .await | |
| 117 | + | .unwrap(); | |
| 118 | + | let invite = makenotwork::db::invites::get_valid_invite_code(&h.db, raw) | |
| 119 | + | .await | |
| 120 | + | .unwrap() | |
| 121 | + | .expect("invite should be valid"); | |
| 122 | + | ||
| 123 | + | let user_a = h.signup("racea", "racea@test.com", "password123").await; | |
| 124 | + | let user_b = h.signup("raceb", "raceb@test.com", "password123").await; | |
| 125 | + | ||
| 126 | + | // Both readers saw the invite as unredeemed; only one write may win. | |
| 127 | + | let first = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_a) | |
| 128 | + | .await | |
| 129 | + | .unwrap(); | |
| 130 | + | let second = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_b) | |
| 131 | + | .await | |
| 132 | + | .unwrap(); | |
| 133 | + | ||
| 134 | + | assert!(first, "first redemption should win the claim"); | |
| 135 | + | assert!(!second, "second redemption of the same code must lose"); | |
| 136 | + | ||
| 137 | + | let redeemer: uuid::Uuid = | |
| 138 | + | sqlx::query_scalar("SELECT redeemed_by_id FROM invite_codes WHERE id = $1") | |
| 139 | + | .bind(invite.id) | |
| 140 | + | .fetch_one(&h.db) | |
| 141 | + | .await | |
| 142 | + | .unwrap(); | |
| 143 | + | assert_eq!(redeemer, *user_a.as_uuid(), "the winner must be the only redeemer"); | |
| 144 | + | } | |
| 145 | + | ||
| 74 | 146 | #[tokio::test] | |
| 75 | 147 | async fn invite_limit_enforced() { | |
| 76 | 148 | let mut h = TestHarness::new().await; |
| @@ -5,6 +5,7 @@ mod custom_pages; | |||
| 5 | 5 | mod sso; | |
| 6 | 6 | mod discover; | |
| 7 | 7 | mod embeds; | |
| 8 | + | mod enum_drift; | |
| 8 | 9 | mod streaming; | |
| 9 | 10 | mod creator; | |
| 10 | 11 | mod purchase; |
| @@ -814,11 +814,13 @@ async fn join_wizard_with_invite_code() { | |||
| 814 | 814 | // Create a creator who can issue invites | |
| 815 | 815 | let creator_id = h.create_creator("jinviter").await; | |
| 816 | 816 | ||
| 817 | - | // Create an invite code directly in DB | |
| 817 | + | // Create an invite code directly in DB. Codes are stored hashed, so insert | |
| 818 | + | // the hash of the raw code the wizard will submit. | |
| 818 | 819 | sqlx::query( | |
| 819 | - | "INSERT INTO invite_codes (creator_id, code) VALUES ($1, 'TESTCODE')", | |
| 820 | + | "INSERT INTO invite_codes (creator_id, code) VALUES ($1, $2)", | |
| 820 | 821 | ) | |
| 821 | 822 | .bind(creator_id) | |
| 823 | + | .bind(makenotwork::crypto::invite_code_hash("TESTCODE")) | |
| 822 | 824 | .execute(&h.db) | |
| 823 | 825 | .await | |
| 824 | 826 | .unwrap(); | |
| @@ -842,10 +844,11 @@ async fn join_wizard_with_invite_code() { | |||
| 842 | 844 | .await; | |
| 843 | 845 | assert!(resp.status.is_success(), "Signup with invite failed: {}", resp.text); | |
| 844 | 846 | ||
| 845 | - | // Verify invite redeemed | |
| 847 | + | // Verify invite redeemed (query by creator; the code column holds a hash now) | |
| 846 | 848 | let redeemed: bool = sqlx::query_scalar( | |
| 847 | - | "SELECT redeemed_by_id IS NOT NULL FROM invite_codes WHERE code = 'TESTCODE'", | |
| 849 | + | "SELECT redeemed_by_id IS NOT NULL FROM invite_codes WHERE creator_id = $1", | |
| 848 | 850 | ) | |
| 851 | + | .bind(creator_id) | |
| 849 | 852 | .fetch_one(&h.db) | |
| 850 | 853 | .await | |
| 851 | 854 | .unwrap(); |