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:
@@ -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