Files
smirk-backend-core/tests/challenges.rs
jwinterm 4f9d00f1d2 feat(db): unified single-use challenge store [operator §0.2]
The shared server-nonce store every signed action consumes (admin login,
first-run setup, self-service erasure). DB-backed so consumption is atomic and
correct under concurrent requests and a future load-balanced fleet:

  DELETE FROM challenges WHERE nonce=$1 AND purpose=$2 AND expires_at > NOW()
                         RETURNING subject

Winning the race returns one row; zero rows = replay/expired/wrong-purpose =>
reject (no TOCTOU). issue_challenge mints a 256-bit nonce bound to a purpose and
optional subject; consume_challenge is the atomic single-use step that pairs with
verify_signed_action (already built, §0.1). Periodic expiry sweep bounds the
table; the table holds no secret and is documented as backup-excluded
(ephemeral, links pubkey->time).

migration 20260625000001; ConsumedChallenge exported; 3 L1 tests (consume-once /
replay-rejected, wrong-purpose rejected, expired rejected).
2026-06-25 17:33:59 -04:00

77 lines
2.1 KiB
Rust

//! L1 integration: the unified challenge (server-nonce) store. Validates atomic
//! single-use consumption, purpose binding, and expiry against real Postgres.
mod common;
#[tokio::test]
async fn issue_then_consume_once() {
let app = require_app!();
let db = &app.state.db;
let nonce = db
.issue_challenge("admin_login", Some("pubkey-x"), 120)
.await
.unwrap();
assert_eq!(nonce.len(), 64, "32-byte hex nonce");
// First consume succeeds and returns the bound subject.
let consumed = db
.consume_challenge(&nonce, "admin_login")
.await
.unwrap()
.expect("nonce consumes once");
assert_eq!(consumed.subject.as_deref(), Some("pubkey-x"));
// Second consume (replay) is rejected.
assert!(
db.consume_challenge(&nonce, "admin_login")
.await
.unwrap()
.is_none(),
"replay rejected"
);
}
#[tokio::test]
async fn consume_rejects_wrong_purpose() {
let app = require_app!();
let db = &app.state.db;
let nonce = db.issue_challenge("setup", None, 120).await.unwrap();
// A nonce minted for one purpose cannot be consumed under another.
assert!(
db.consume_challenge(&nonce, "admin_login")
.await
.unwrap()
.is_none(),
"purpose mismatch rejected"
);
// The original purpose still works (the failed attempt didn't consume it).
assert!(db
.consume_challenge(&nonce, "setup")
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn expired_nonce_is_not_consumable() {
let app = require_app!();
let db = &app.state.db;
// TTL 0 => already expired by the time we consume.
let nonce = db
.issue_challenge("erasure_request", None, 0)
.await
.unwrap();
assert!(
db.consume_challenge(&nonce, "erasure_request")
.await
.unwrap()
.is_none(),
"expired nonce rejected"
);
// Sweeper removes it.
let _ = db.delete_expired_challenges().await.unwrap();
}