mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(registration): composable gate framework + invite codes (gate #1)
First of the operator-configurable registration gates (PoW-priced restore and pay-to-register follow). A NEW wallet must present a valid invite code when REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it, and the active gates are advertised via /capabilities so the wallet shapes onboarding. - migration + db: invite_codes table storing only sha256(code) (128-bit codes, no pepper needed). Redemption is an atomic single-statement claim (UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent submission (L1 test fires 8 concurrent claims; exactly one wins). - core::invite: generate (base58, 128-bit) + sha256 hash. - enforce_invite in extension_register, mirroring enforce_pow (returning bypass; ONE literal error for not-found/used/expired — never an oracle). - RegistrationConfig.require_invite + RegistrationCapability in /capabilities. - smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes). - L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
This commit is contained in:
@@ -27,6 +27,13 @@ ALTCHA_HMAC_KEY=
|
||||
POW_REQUIRED=false
|
||||
ALTCHA_COST=100000
|
||||
|
||||
# ── Registration gates (composable; each advertised via /capabilities) ────
|
||||
# Require an operator-minted invite code to create a NEW wallet. Mint codes with
|
||||
# `smirk-admin mint-invite --count <n>`. Returning wallets bypass it, and
|
||||
# self-hosting bypasses all gates (run your own backend). PoW is the ALTCHA gate
|
||||
# above; pay-to-register is planned. Compose freely (e.g. invite + PoW).
|
||||
REGISTRATION_REQUIRE_INVITE=false
|
||||
|
||||
# ── Bitcoin ───────────────────────────────────────────────────────────────
|
||||
BTC_NETWORK=mainnet
|
||||
BTC_USE_LOCAL_NODE=false
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Operator-minted registration invite codes — one composable registration gate
|
||||
-- (alongside PoW and, later, pay-to-register). v1 is single-use, operator-minted
|
||||
-- via the `smirk-admin mint-invite` CLI; the schema leaves room for multi-use,
|
||||
-- expiry, and referral without a breaking change.
|
||||
--
|
||||
-- Only sha256(code) is stored. The raw code is a bearer secret shown once at
|
||||
-- mint time and never persisted, so a database leak is NOT a list of usable
|
||||
-- codes — codes are >=128-bit random, so the hash is not brute-forceable and no
|
||||
-- pepper is needed.
|
||||
CREATE TABLE invite_codes (
|
||||
code_hash TEXT PRIMARY KEY, -- hex sha256 of the raw code
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
label TEXT, -- optional operator note (batch / who-for)
|
||||
expires_at TIMESTAMPTZ, -- NULL = never (reserved; v1 mints without expiry)
|
||||
used_at TIMESTAMPTZ, -- NULL = unused (single-use)
|
||||
used_by UUID REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Fast lookup of still-spendable codes (mint listing / future sweeps).
|
||||
CREATE INDEX idx_invite_codes_unused ON invite_codes (created_at) WHERE used_at IS NULL;
|
||||
+31
-1
@@ -1892,7 +1892,8 @@
|
||||
"contract_version",
|
||||
"chains",
|
||||
"features",
|
||||
"restore"
|
||||
"restore",
|
||||
"registration"
|
||||
],
|
||||
"properties": {
|
||||
"chains": {
|
||||
@@ -1907,6 +1908,10 @@
|
||||
"features": {
|
||||
"$ref": "#/components/schemas/FeatureCapabilities"
|
||||
},
|
||||
"registration": {
|
||||
"$ref": "#/components/schemas/RegistrationCapability",
|
||||
"description": "Registration gates for a new wallet on this instance."
|
||||
},
|
||||
"restore": {
|
||||
"$ref": "#/components/schemas/RestoreCapability",
|
||||
"description": "Wallet restore (import) policy for this instance."
|
||||
@@ -2099,6 +2104,13 @@
|
||||
"type": "object",
|
||||
"description": "Optional proof-of-work solution. Required when the PoW gate applies to\nthis pubkey (see [`pow_applies`]); otherwise ignored."
|
||||
},
|
||||
"invite_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Operator-minted invite code. Required when the instance enables the invite\nregistration gate (see `/capabilities` → `registration.invite_required`);\nignored otherwise and for returning wallets."
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -2912,6 +2924,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RegistrationCapability": {
|
||||
"type": "object",
|
||||
"description": "Registration gates this instance enforces for a NEW wallet (returning wallets\nand self-hosting bypass them). The wallet uses these to shape onboarding —\nprompt for an invite code, solve PoW, etc.",
|
||||
"required": [
|
||||
"invite_required",
|
||||
"pow_required"
|
||||
],
|
||||
"properties": {
|
||||
"invite_required": {
|
||||
"type": "boolean",
|
||||
"description": "A valid operator-minted invite code is required to register."
|
||||
},
|
||||
"pow_required": {
|
||||
"type": "boolean",
|
||||
"description": "A proof-of-work solution is required to register."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RelayEntry": {
|
||||
"type": "object",
|
||||
"description": "A relay entry as returned to an authorized party.",
|
||||
|
||||
@@ -303,6 +303,11 @@ pub struct ExtensionRegisterRequest {
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Object)]
|
||||
pub altcha_solution: Option<altcha::Payload>,
|
||||
/// Operator-minted invite code. Required when the instance enables the invite
|
||||
/// registration gate (see `/capabilities` → `registration.invite_required`);
|
||||
/// ignored otherwise and for returning wallets.
|
||||
#[serde(default)]
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
/// Register a new extension wallet or authenticate an existing one.
|
||||
@@ -460,6 +465,7 @@ pub async fn extension_register(
|
||||
returning_by_pubkey,
|
||||
req.altcha_solution.as_ref(),
|
||||
)?;
|
||||
enforce_invite(&state, returning_by_pubkey, req.invite_code.as_deref()).await?;
|
||||
|
||||
// If we reached here after an unproven fingerprint match, do NOT attach that
|
||||
// fingerprint to the new row — it belongs to another user, and the UNIQUE
|
||||
@@ -579,6 +585,48 @@ fn enforce_pow(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invite-code registration gate. A genuinely new wallet must present a valid,
|
||||
/// unused operator-minted invite code when the instance requires one; a
|
||||
/// returning wallet (known pubkey) bypasses it, exactly like PoW, and an
|
||||
/// instance with the gate off accepts everyone. The claim is atomic and
|
||||
/// single-use (db layer), so a code is redeemable by at most one registration
|
||||
/// even under concurrent submission. Self-hosting bypasses this entirely
|
||||
/// (operators simply leave the gate off).
|
||||
async fn enforce_invite(
|
||||
state: &AppState,
|
||||
returning: bool,
|
||||
invite_code: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
if returning || !state.config.registration.require_invite {
|
||||
return Ok(());
|
||||
}
|
||||
let code = invite_code
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or_else(|| {
|
||||
warn!(
|
||||
gate = "invite",
|
||||
"invite required for new wallet but none supplied"
|
||||
);
|
||||
AppError::ValidationError(
|
||||
"An invite code is required to register on this instance.".into(),
|
||||
)
|
||||
})?;
|
||||
if !state
|
||||
.db
|
||||
.claim_invite_code(&crate::core::invite::hash_invite_code(code))
|
||||
.await?
|
||||
{
|
||||
// One literal for "no such code", "already used", and "expired" — never
|
||||
// an oracle that distinguishes them.
|
||||
return Err(AppError::ValidationError(
|
||||
"Invalid or already-used invite code.".into(),
|
||||
));
|
||||
}
|
||||
info!(gate = "invite", "invite code accepted (new user)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SHA-256 hex of a public key string — the wallet's stable identity handle.
|
||||
fn hash_public_key(public_key: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -60,6 +60,17 @@ pub struct RestoreCapability {
|
||||
pub max_depth_days: Option<u32>,
|
||||
}
|
||||
|
||||
/// Registration gates this instance enforces for a NEW wallet (returning wallets
|
||||
/// and self-hosting bypass them). The wallet uses these to shape onboarding —
|
||||
/// prompt for an invite code, solve PoW, etc.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct RegistrationCapability {
|
||||
/// A valid operator-minted invite code is required to register.
|
||||
pub invite_required: bool,
|
||||
/// A proof-of-work solution is required to register.
|
||||
pub pow_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct CapabilitiesResponse {
|
||||
/// Backend version (Cargo package version).
|
||||
@@ -70,6 +81,8 @@ pub struct CapabilitiesResponse {
|
||||
pub features: FeatureCapabilities,
|
||||
/// Wallet restore (import) policy for this instance.
|
||||
pub restore: RestoreCapability,
|
||||
/// Registration gates for a new wallet on this instance.
|
||||
pub registration: RegistrationCapability,
|
||||
}
|
||||
|
||||
/// Whether an enabled chain can actually be served (its infra secret/URL is
|
||||
@@ -135,6 +148,10 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
registration: RegistrationCapability {
|
||||
invite_required: config.registration.require_invite,
|
||||
pow_required: config.pow.enabled && config.pow.required,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ use utoipa::OpenApi;
|
||||
crate::api::capabilities::ChainCapability,
|
||||
crate::api::capabilities::FeatureCapabilities,
|
||||
crate::api::capabilities::RestoreCapability,
|
||||
crate::api::capabilities::RegistrationCapability,
|
||||
crate::api::prices::PricesResponse,
|
||||
crate::api::auth::AuthResponse,
|
||||
crate::api::auth::UserInfo,
|
||||
|
||||
@@ -26,6 +26,7 @@ use rand::RngCore;
|
||||
use uuid::Uuid;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code};
|
||||
use smirk_backend_core::infra::db::{AddKeyOutcome, Database, RevokeKeyOutcome};
|
||||
use smirk_backend_core::models::db::{NewAdminAudit, NewAdminKey};
|
||||
|
||||
@@ -43,6 +44,7 @@ COMMANDS:
|
||||
revoke-key --id <uuid>
|
||||
replace-key --old <uuid> --pubkey <64hex> [--revoke-all]
|
||||
create-admin-wallet --out <path>
|
||||
mint-invite [--count <n>] [--label <s>] (registration invite codes)
|
||||
doctor
|
||||
";
|
||||
|
||||
@@ -93,6 +95,7 @@ async fn run(args: &[String]) -> Result<(), String> {
|
||||
"revoke-key" => revoke_key(&db, &secret, rest).await,
|
||||
"replace-key" => replace_key(&db, &secret, rest).await,
|
||||
"create-admin-wallet" => create_admin_wallet(&db, &secret, rest).await,
|
||||
"mint-invite" => mint_invite(&db, rest).await,
|
||||
"doctor" => doctor(&db, &secret).await,
|
||||
"-h" | "--help" | "help" => {
|
||||
print!("{USAGE}");
|
||||
@@ -367,6 +370,36 @@ async fn create_admin_wallet(db: &Database, secret: &str, args: &[String]) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint single-use registration invite codes. Generates random 128-bit codes,
|
||||
/// stores only their sha256 hashes, and prints the RAW codes to stdout once for
|
||||
/// distribution — they are not recoverable afterward (only the hash is kept).
|
||||
async fn mint_invite(db: &Database, args: &[String]) -> Result<(), String> {
|
||||
let count: u32 = match flag(args, "--count") {
|
||||
Some(s) => s
|
||||
.parse()
|
||||
.map_err(|_| "--count must be a number".to_string())?,
|
||||
None => 1,
|
||||
}
|
||||
.clamp(1, 1000);
|
||||
let label = flag(args, "--label");
|
||||
eprintln!(
|
||||
"Minting {count} single-use invite code(s){}:",
|
||||
label
|
||||
.as_deref()
|
||||
.map(|l| format!(" [{l}]"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
for _ in 0..count {
|
||||
let code = generate_invite_code();
|
||||
db.insert_invite_code(&hash_invite_code(&code), label.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("insert invite: {e}"))?;
|
||||
println!("{code}");
|
||||
}
|
||||
eprintln!("\nDistribute now — only the hash is stored; raw codes are not recoverable.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn doctor(db: &Database, secret: &str) -> Result<(), String> {
|
||||
println!("database: {}", health(db).await);
|
||||
let live = db
|
||||
|
||||
@@ -129,6 +129,7 @@ pub struct Config {
|
||||
pub landing: LandingConfig,
|
||||
pub retention: RetentionConfig,
|
||||
pub restore: RestoreConfig,
|
||||
pub registration: RegistrationConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -392,6 +393,16 @@ impl RestoreConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration gates beyond PoW — composable, operator-configured, advertised
|
||||
/// via `/capabilities`. Each is a gate the wallet must satisfy to create a NEW
|
||||
/// identity; returning wallets bypass them, and self-hosting bypasses all of
|
||||
/// them (run your own backend). PoW lives in [`PowConfig`]; this holds the rest.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RegistrationConfig {
|
||||
/// Require a valid operator-minted invite code to register a new wallet.
|
||||
pub require_invite: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from the environment and validate it (fail-closed).
|
||||
pub fn from_env() -> Result<Self, AppError> {
|
||||
@@ -560,6 +571,9 @@ impl Config {
|
||||
max_depth_days: env_parse("WALLET_MAX_RESTORE_DEPTH_DAYS", 365u32)?,
|
||||
}
|
||||
},
|
||||
registration: RegistrationConfig {
|
||||
require_invite: env_bool("REGISTRATION_REQUIRE_INVITE", false),
|
||||
},
|
||||
};
|
||||
|
||||
cfg.validate()?;
|
||||
@@ -857,6 +871,9 @@ mod tests {
|
||||
policy: RestorePolicy::Bounded,
|
||||
max_depth_days: 365,
|
||||
},
|
||||
registration: RegistrationConfig {
|
||||
require_invite: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Registration invite codes: generation + hashing.
|
||||
//!
|
||||
//! A code is 16 random bytes (128 bits) rendered as base58 for legible operator
|
||||
//! distribution. Only `sha256(code)` is ever stored (see [`crate::infra::db`]);
|
||||
//! the raw code is shown once, at mint time. 128 bits of entropy makes the
|
||||
//! stored hash non-brute-forceable, so no pepper is needed (unlike user-chosen
|
||||
//! secrets). Single-use is enforced by an atomic claim in the db layer.
|
||||
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Generate a fresh random invite code (128-bit, base58).
|
||||
pub fn generate_invite_code() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bs58::encode(bytes).into_string()
|
||||
}
|
||||
|
||||
/// Hex `sha256` of a raw invite code — the stored form and the lookup key.
|
||||
pub fn hash_invite_code(code: &str) -> String {
|
||||
hex::encode(Sha256::digest(code.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_is_deterministic_and_hex64() {
|
||||
let h = hash_invite_code("abc");
|
||||
assert_eq!(h, hash_invite_code("abc"));
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
assert_ne!(h, hash_invite_code("abd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_codes_are_unique_and_nonempty() {
|
||||
let a = generate_invite_code();
|
||||
let b = generate_invite_code();
|
||||
assert!(!a.is_empty());
|
||||
assert_ne!(a, b);
|
||||
// base58 of 16 bytes is ~22 chars; never trivially short.
|
||||
assert!(a.len() >= 16);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod admin_session;
|
||||
pub mod crypto;
|
||||
pub mod invite;
|
||||
pub mod pow;
|
||||
pub mod secret;
|
||||
pub mod session;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Registration invite-code queries — one of the composable registration gates.
|
||||
//!
|
||||
//! Single-use, operator-minted. Only `sha256(code)` is stored (the raw code is a
|
||||
//! bearer secret, shown once at mint time). Redemption is an atomic
|
||||
//! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so
|
||||
//! two concurrent registrations presenting the same code can never both succeed.
|
||||
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::Database;
|
||||
|
||||
impl Database {
|
||||
/// Store a freshly-minted code by its `sha256` hash with an optional operator
|
||||
/// `label`. A duplicate hash (re-mint of the same raw code — astronomically
|
||||
/// unlikely for a 128-bit code) surfaces as a unique-violation error.
|
||||
#[instrument(skip(self, code_hash))]
|
||||
pub async fn insert_invite_code(
|
||||
&self,
|
||||
code_hash: &str,
|
||||
label: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("INSERT INTO invite_codes (code_hash, label) VALUES ($1, $2)")
|
||||
.bind(code_hash)
|
||||
.bind(label)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically redeem a single-use code by its hash. Returns `true` iff a
|
||||
/// still-unused, unexpired code was consumed by THIS call. The
|
||||
/// `WHERE used_at IS NULL` predicate and `RETURNING` make check-and-consume a
|
||||
/// single statement, so concurrent redemptions race safely — exactly one wins.
|
||||
#[instrument(skip(self, code_hash))]
|
||||
pub async fn claim_invite_code(&self, code_hash: &str) -> Result<bool, AppError> {
|
||||
let claimed = sqlx::query_scalar::<_, String>(
|
||||
"UPDATE invite_codes SET used_at = NOW() \
|
||||
WHERE code_hash = $1 AND used_at IS NULL \
|
||||
AND (expires_at IS NULL OR expires_at > NOW()) \
|
||||
RETURNING code_hash",
|
||||
)
|
||||
.bind(code_hash)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(claimed.is_some())
|
||||
}
|
||||
|
||||
/// Count of still-spendable (unused, unexpired) codes — for `smirk-admin doctor`.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn unused_invite_code_count(&self) -> Result<i64, AppError> {
|
||||
Ok(sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM invite_codes \
|
||||
WHERE used_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())",
|
||||
)
|
||||
.fetch_one(self.pool())
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod audit;
|
||||
mod challenges;
|
||||
mod erasure;
|
||||
mod grin_slatepacks;
|
||||
mod invite_codes;
|
||||
mod login_events;
|
||||
mod restore_attempts;
|
||||
mod server_config;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! L1: invite-code registration gate.
|
||||
//!
|
||||
//! The security-critical invariant is **atomic single-use**: a code redeems at
|
||||
//! most ONE registration, even under concurrent submission (otherwise one invite
|
||||
//! grants unbounded Sybil registrations). The redemption is a single-statement
|
||||
//! `UPDATE ... WHERE used_at IS NULL ... RETURNING`, so the database enforces it;
|
||||
//! these tests lock that in (incl. a concurrency race).
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invite_code_is_single_use() {
|
||||
let app = require_app!();
|
||||
let hash = hash_invite_code(&generate_invite_code());
|
||||
app.state
|
||||
.db
|
||||
.insert_invite_code(&hash, Some("test-batch"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
app.state.db.claim_invite_code(&hash).await.unwrap(),
|
||||
"first redemption must succeed"
|
||||
);
|
||||
assert!(
|
||||
!app.state.db.claim_invite_code(&hash).await.unwrap(),
|
||||
"a reused code must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_invite_code_is_rejected() {
|
||||
let app = require_app!();
|
||||
let unknown = hash_invite_code(&generate_invite_code());
|
||||
assert!(
|
||||
!app.state.db.claim_invite_code(&unknown).await.unwrap(),
|
||||
"a never-minted code must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_claims_redeem_at_most_once() {
|
||||
let app = require_app!();
|
||||
let hash = hash_invite_code(&generate_invite_code());
|
||||
app.state.db.insert_invite_code(&hash, None).await.unwrap();
|
||||
|
||||
// Fire many concurrent redemptions of the SAME code; exactly one may win.
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let state = Arc::clone(&app.state);
|
||||
let h = hash.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
state.db.claim_invite_code(&h).await.unwrap()
|
||||
}));
|
||||
}
|
||||
let mut wins = 0;
|
||||
for handle in handles {
|
||||
if handle.await.unwrap() {
|
||||
wins += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
wins, 1,
|
||||
"exactly one concurrent claim may redeem a single-use code"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unused_count_is_queryable_and_nonnegative() {
|
||||
// The unused count is a global doctor metric, so under parallel tests we
|
||||
// can't assert exact deltas (siblings mint/claim concurrently). We assert it
|
||||
// is queryable and reflects at least our own still-unused code; the "claimed
|
||||
// codes are excluded" property is the same `used_at IS NULL` predicate the
|
||||
// single-use test already locks in.
|
||||
let app = require_app!();
|
||||
app.state
|
||||
.db
|
||||
.insert_invite_code(&hash_invite_code(&generate_invite_code()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
app.state.db.unused_invite_code_count().await.unwrap() >= 1,
|
||||
"count must include the just-minted unused code"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user