mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
## Summary - add database-backed v2 invite links with optional maximum-use limits and atomic final-slot redemption - preserve v1 invite compatibility while adding exhausted/expired/invalid client handling across desktop, web, and mobile - emit structured claim-outcome logs with community, invite ID, outcome, maximum uses, and post-claim count ## Verification - `cargo fmt --all -- --check` - `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored) - `cargo clippy -p buzz-db --all-targets -- -D warnings` - desktop `npm run typecheck` - push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and branch-skew passed - Postgres integration tests were previously reviewed green at the pre-rebase tree; local rerun on this session was unavailable because Postgres/Docker were not running - mobile push-hook check could not start because Flutter is unavailable locally --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
672 lines
23 KiB
Rust
672 lines
23 KiB
Rust
//! Use-limited relay invite persistence (v2 opaque tokens).
|
|
//!
|
|
//! Unlike the stateless v1 HMAC invite tokens in `buzz-relay::invite_token`,
|
|
//! v2 invites are backed by durable rows in `relay_invites`. The table stores
|
|
//! only `SHA-256(code)` — never the reusable bearer secret — so a leaked
|
|
//! database does not immediately yield valid invite codes.
|
|
//!
|
|
//! Every lookup binds both `(community_id, token_hash)` to prevent cross-tenant
|
|
//! authorization seams: a code minted on tenant A presented to tenant B returns
|
|
//! `Invalid`, not a membership.
|
|
//!
|
|
//! ## Atomic redemption
|
|
//!
|
|
//! `claim_relay_invite` executes the full redemption in one PostgreSQL
|
|
//! transaction: `SELECT FOR UPDATE` on the invite row, membership insert,
|
|
//! join-policy evidence insert, and `use_count` increment all commit together.
|
|
//! `FOR UPDATE` serializes concurrent claims for one invite across relay
|
|
//! processes — exactly one claimant can win the final slot.
|
|
|
|
use buzz_core::invite::{
|
|
encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS,
|
|
V2_SECRET_LEN,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::{PgPool, Row as _};
|
|
|
|
use crate::error::Result;
|
|
use crate::CommunityId;
|
|
|
|
/// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are
|
|
/// typed variants so the relay layer can map them to distinct HTTP responses
|
|
/// without inspecting database errors.
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum ClaimOutcome {
|
|
/// A new relay member was inserted. `use_count` is the post-increment count;
|
|
/// `uses_remaining` is `None` for unlimited invites.
|
|
Joined {
|
|
/// Post-claim use count.
|
|
use_count: i32,
|
|
/// Remaining slots, or `None` when the invite is unlimited.
|
|
uses_remaining: Option<i32>,
|
|
},
|
|
/// The claimer was already a member. `use_count` was NOT incremented.
|
|
AlreadyMember {
|
|
/// Current use count (unchanged by this claim).
|
|
use_count: i32,
|
|
/// Remaining slots, or `None` when the invite is unlimited.
|
|
uses_remaining: Option<i32>,
|
|
},
|
|
/// The invite's `expires_at` has passed.
|
|
Expired,
|
|
/// The invite's use budget is fully consumed.
|
|
Exhausted,
|
|
/// No invite row matches `(community_id, token_hash)`.
|
|
Invalid,
|
|
}
|
|
|
|
/// A freshly minted v2 invite, including the plaintext code and metadata.
|
|
#[derive(Debug)]
|
|
pub struct MintedInvite {
|
|
/// The full v2 code string (`v2.<base64url secret>`). Returned to the caller
|
|
/// exactly once; the database stores only the SHA-256 hash.
|
|
pub code: String,
|
|
/// When the invite expires (UTC).
|
|
pub expires_at: DateTime<Utc>,
|
|
/// `None` means unlimited; `Some(n)` means at most `n` uses.
|
|
pub max_uses: Option<i32>,
|
|
/// Remaining uses at mint time (equals `max_uses` when bounded, `None`
|
|
/// when unlimited).
|
|
pub uses_remaining: Option<i32>,
|
|
/// The invite's database-generated UUID.
|
|
pub invite_id: uuid::Uuid,
|
|
}
|
|
|
|
fn validate_mint_inputs(ttl_secs: u64, max_uses: Option<i32>) -> Result<()> {
|
|
if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl_secs) {
|
|
return Err(crate::error::DbError::InvalidData(format!(
|
|
"ttl_secs must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS}"
|
|
)));
|
|
}
|
|
|
|
if let Some(max_uses) = max_uses {
|
|
if !(1..=MAX_INVITE_USES).contains(&max_uses) {
|
|
return Err(crate::error::DbError::InvalidData(format!(
|
|
"max_uses must be between 1 and {MAX_INVITE_USES}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the
|
|
/// row, and return the plaintext code plus metadata.
|
|
///
|
|
/// `ttl_secs` must be in the shared invite lifetime range.
|
|
/// `max_uses` must be `None` (unlimited) or `Some(1..=10000)`.
|
|
pub async fn mint_relay_invite(
|
|
pool: &PgPool,
|
|
community: CommunityId,
|
|
created_by: &str,
|
|
ttl_secs: u64,
|
|
max_uses: Option<i32>,
|
|
) -> Result<MintedInvite> {
|
|
validate_mint_inputs(ttl_secs, max_uses)?;
|
|
|
|
// Generate 32 random bytes and encode as base64url — this is the secret.
|
|
let secret: [u8; V2_SECRET_LEN] = rand::random();
|
|
let code = encode_v2_code(&secret);
|
|
let token_hash = hash_v2_code(&code);
|
|
let now = Utc::now();
|
|
let expires_at = now + chrono::Duration::seconds(ttl_secs as i64);
|
|
|
|
let row = sqlx::query(
|
|
"INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \
|
|
VALUES ($1, $2, $3, $4, $5) \
|
|
RETURNING id",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(token_hash.as_slice())
|
|
.bind(max_uses)
|
|
.bind(expires_at)
|
|
.bind(created_by)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
|
|
let invite_id: uuid::Uuid = row.try_get("id")?;
|
|
|
|
Ok(MintedInvite {
|
|
code,
|
|
expires_at,
|
|
max_uses,
|
|
uses_remaining: max_uses,
|
|
invite_id,
|
|
})
|
|
}
|
|
|
|
fn log_claim_outcome(
|
|
community: CommunityId,
|
|
invite_id: Option<uuid::Uuid>,
|
|
outcome: &'static str,
|
|
max_uses: Option<i32>,
|
|
use_count: Option<i32>,
|
|
) {
|
|
tracing::info!(
|
|
community = %community,
|
|
invite_id = ?invite_id,
|
|
outcome,
|
|
max_uses = ?max_uses,
|
|
use_count = ?use_count,
|
|
"relay invite claim completed"
|
|
);
|
|
}
|
|
|
|
/// Maximum rows deleted by one retention sweep so cleanup cannot monopolize
|
|
/// the invite table on a busy deployment.
|
|
const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000;
|
|
|
|
/// Delete one bounded batch of invite rows expired before `cutoff`.
|
|
///
|
|
/// The relay calls this from its leader-only periodic tick. Ordering by the
|
|
/// expiry index makes old rows drain first without turning cleanup into an
|
|
/// unbounded transaction.
|
|
pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime<Utc>) -> Result<u64> {
|
|
let result = sqlx::query(
|
|
"DELETE FROM relay_invites \
|
|
WHERE (community_id, id) IN (\
|
|
SELECT community_id, id FROM relay_invites \
|
|
WHERE expires_at < $1 \
|
|
ORDER BY expires_at \
|
|
LIMIT $2\
|
|
)",
|
|
)
|
|
.bind(cutoff)
|
|
.bind(RETENTION_SWEEP_BATCH_SIZE)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(result.rows_affected())
|
|
}
|
|
|
|
/// Atomically claim a v2 relay invite.
|
|
///
|
|
/// Executes the full redemption in one PostgreSQL transaction:
|
|
/// 1. Hash the presented code.
|
|
/// 2. `SELECT ... FOR UPDATE` on the invite row scoped by `(community, token_hash)`.
|
|
/// 3. If no row → `Invalid`.
|
|
/// 4. If `expires_at <= now()` → `Expired`.
|
|
/// 5. Check existing membership.
|
|
/// 6. If already a member → insert policy evidence (if configured), commit,
|
|
/// return `AlreadyMember` (no increment).
|
|
/// 7. If `max_uses` is set and `use_count >= max_uses` → `Exhausted`.
|
|
/// 8. Insert relay member with role `member`, `added_by = 'invite'`.
|
|
/// 9. Insert join-policy acceptance evidence (if configured).
|
|
/// 10. Increment `use_count`.
|
|
/// 11. Commit.
|
|
///
|
|
/// `FOR UPDATE` serializes concurrent claims so exactly one claimant wins the
|
|
/// final slot. Membership insertion, policy evidence, and consumption share
|
|
/// one commit — a failure in any rolls back all.
|
|
pub async fn claim_relay_invite(
|
|
pool: &PgPool,
|
|
community: CommunityId,
|
|
token_hash: &[u8; 32],
|
|
claimer_pubkey: &str,
|
|
policy_version: Option<&str>,
|
|
) -> Result<ClaimOutcome> {
|
|
let mut tx = pool.begin().await?;
|
|
|
|
// 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn.
|
|
let row = sqlx::query(
|
|
"SELECT id, max_uses, use_count, expires_at \
|
|
FROM relay_invites \
|
|
WHERE community_id = $1 AND token_hash = $2 \
|
|
FOR UPDATE",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(token_hash)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
// 3. No matching invite.
|
|
let Some(invite) = row else {
|
|
tx.rollback().await?;
|
|
log_claim_outcome(community, None, "invalid", None, None);
|
|
return Ok(ClaimOutcome::Invalid);
|
|
};
|
|
|
|
let invite_id: uuid::Uuid = invite.try_get("id")?;
|
|
let max_uses: Option<i32> = invite.try_get("max_uses")?;
|
|
let use_count: i32 = invite.try_get("use_count")?;
|
|
let expires_at: DateTime<Utc> = invite.try_get("expires_at")?;
|
|
|
|
// Expiry is checked before membership deliberately. An expired bearer must
|
|
// not authorize fresh policy-acceptance evidence, even for an existing
|
|
// member; exhausted-but-live invites remain valid for idempotent retries.
|
|
if expires_at <= Utc::now() {
|
|
tx.rollback().await?;
|
|
log_claim_outcome(
|
|
community,
|
|
Some(invite_id),
|
|
"expired",
|
|
max_uses,
|
|
Some(use_count),
|
|
);
|
|
return Ok(ClaimOutcome::Expired);
|
|
}
|
|
|
|
let uses_remaining = || max_uses.map(|mu| mu - use_count);
|
|
|
|
// 5. Check existing membership.
|
|
let existing =
|
|
sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
|
.bind(community.as_uuid())
|
|
.bind(claimer_pubkey)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
if existing.is_some() {
|
|
// 6. Already a member — insert policy evidence but do NOT increment.
|
|
if let Some(version) = policy_version {
|
|
sqlx::query(
|
|
"INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
|
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(claimer_pubkey)
|
|
.bind(version)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
log_claim_outcome(
|
|
community,
|
|
Some(invite_id),
|
|
"already_member",
|
|
max_uses,
|
|
Some(use_count),
|
|
);
|
|
return Ok(ClaimOutcome::AlreadyMember {
|
|
use_count,
|
|
uses_remaining: uses_remaining(),
|
|
});
|
|
}
|
|
|
|
// 7. Capacity check.
|
|
if let Some(mu) = max_uses {
|
|
if use_count >= mu {
|
|
tx.rollback().await?;
|
|
log_claim_outcome(
|
|
community,
|
|
Some(invite_id),
|
|
"exhausted",
|
|
max_uses,
|
|
Some(use_count),
|
|
);
|
|
return Ok(ClaimOutcome::Exhausted);
|
|
}
|
|
}
|
|
|
|
// 8. Insert relay member. The conflict branch covers a claimant admitted
|
|
// concurrently through a different invite: only the transaction that
|
|
// actually inserted membership may consume this invite.
|
|
let inserted = sqlx::query(
|
|
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
|
|
VALUES ($1, $2, 'member', 'invite') \
|
|
ON CONFLICT (community_id, pubkey) DO NOTHING",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(claimer_pubkey)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected()
|
|
> 0;
|
|
|
|
// 9. Insert join-policy acceptance evidence. This is required for both a
|
|
// new member and a claimant whose concurrent membership insert won first.
|
|
if let Some(version) = policy_version {
|
|
sqlx::query(
|
|
"INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
|
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(claimer_pubkey)
|
|
.bind(version)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
if !inserted {
|
|
tx.commit().await?;
|
|
log_claim_outcome(
|
|
community,
|
|
Some(invite_id),
|
|
"already_member",
|
|
max_uses,
|
|
Some(use_count),
|
|
);
|
|
return Ok(ClaimOutcome::AlreadyMember {
|
|
use_count,
|
|
uses_remaining: uses_remaining(),
|
|
});
|
|
}
|
|
|
|
// 10. Increment use_count (for every new member, even unlimited).
|
|
let new_use_count = use_count + 1;
|
|
sqlx::query("UPDATE relay_invites SET use_count = $1 WHERE community_id = $2 AND id = $3")
|
|
.bind(new_use_count)
|
|
.bind(community.as_uuid())
|
|
.bind(invite_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// 11. Commit.
|
|
tx.commit().await?;
|
|
|
|
let new_uses_remaining = max_uses.map(|mu| mu - new_use_count);
|
|
|
|
log_claim_outcome(
|
|
community,
|
|
Some(invite_id),
|
|
"joined",
|
|
max_uses,
|
|
Some(new_use_count),
|
|
);
|
|
|
|
Ok(ClaimOutcome::Joined {
|
|
use_count: new_use_count,
|
|
uses_remaining: new_uses_remaining,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::relay_members::is_relay_member;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
|
|
|
async fn setup_pool() -> PgPool {
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
PgPool::connect(&database_url)
|
|
.await
|
|
.expect("connect to test DB")
|
|
}
|
|
|
|
async fn make_test_community(pool: &PgPool) -> CommunityId {
|
|
let id = Uuid::new_v4();
|
|
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
|
.bind(id)
|
|
.bind(format!("relay-invite-test-{}.example", id.simple()))
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert test community");
|
|
CommunityId::from_uuid(id)
|
|
}
|
|
|
|
async fn delete_test_community(pool: &PgPool, community: CommunityId) {
|
|
let mut tx = pool.begin().await.expect("begin test cleanup");
|
|
sqlx::query("DELETE FROM relay_invites WHERE community_id = $1")
|
|
.bind(community.as_uuid())
|
|
.execute(&mut *tx)
|
|
.await
|
|
.expect("delete test invites");
|
|
sqlx::query("DELETE FROM relay_members WHERE community_id = $1")
|
|
.bind(community.as_uuid())
|
|
.execute(&mut *tx)
|
|
.await
|
|
.expect("delete test members");
|
|
sqlx::query("DELETE FROM communities WHERE id = $1")
|
|
.bind(community.as_uuid())
|
|
.execute(&mut *tx)
|
|
.await
|
|
.expect("delete test community");
|
|
tx.commit().await.expect("commit test cleanup");
|
|
}
|
|
|
|
fn test_pubkey() -> String {
|
|
format!("{:064x}", Uuid::new_v4().as_u128())
|
|
}
|
|
|
|
async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 {
|
|
sqlx::query_scalar(
|
|
"SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(invite_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("read invite use_count")
|
|
}
|
|
|
|
#[test]
|
|
fn mint_validation_rejects_invalid_bounds_before_database_access() {
|
|
for (ttl, max_uses) in [
|
|
(MIN_INVITE_TTL_SECS - 1, None),
|
|
(MAX_INVITE_TTL_SECS + 1, None),
|
|
(3600, Some(0)),
|
|
(3600, Some(-1)),
|
|
(3600, Some(MAX_INVITE_USES + 1)),
|
|
] {
|
|
let error = validate_mint_inputs(ttl, max_uses).expect_err("invalid mint contract");
|
|
assert!(matches!(error, crate::DbError::InvalidData(_)), "{error:?}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn bounded_claim_exhausts_and_existing_member_retry_does_not_consume() {
|
|
let pool = setup_pool().await;
|
|
let community = make_test_community(&pool).await;
|
|
let first = test_pubkey();
|
|
let second = test_pubkey();
|
|
let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
|
.await
|
|
.expect("mint bounded invite");
|
|
let hash = hash_v2_code(&invite.code);
|
|
|
|
assert_eq!(
|
|
claim_relay_invite(&pool, community, &hash, &first, None)
|
|
.await
|
|
.expect("first claim"),
|
|
ClaimOutcome::Joined {
|
|
use_count: 1,
|
|
uses_remaining: Some(0),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
claim_relay_invite(&pool, community, &hash, &first, None)
|
|
.await
|
|
.expect("idempotent retry"),
|
|
ClaimOutcome::AlreadyMember {
|
|
use_count: 1,
|
|
uses_remaining: Some(0),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
claim_relay_invite(&pool, community, &hash, &second, None)
|
|
.await
|
|
.expect("exhausted claim"),
|
|
ClaimOutcome::Exhausted
|
|
);
|
|
assert_eq!(use_count(&pool, community, invite.invite_id).await, 1);
|
|
assert!(is_relay_member(&pool, community, &first)
|
|
.await
|
|
.expect("first membership"));
|
|
assert!(!is_relay_member(&pool, community, &second)
|
|
.await
|
|
.expect("second membership"));
|
|
delete_test_community(&pool, community).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn concurrent_claims_serialize_the_final_slot() {
|
|
let pool = setup_pool().await;
|
|
let community = make_test_community(&pool).await;
|
|
let first = test_pubkey();
|
|
let second = test_pubkey();
|
|
let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
|
.await
|
|
.expect("mint bounded invite");
|
|
let hash = hash_v2_code(&invite.code);
|
|
|
|
let (first_outcome, second_outcome) = tokio::join!(
|
|
claim_relay_invite(&pool, community, &hash, &first, None),
|
|
claim_relay_invite(&pool, community, &hash, &second, None),
|
|
);
|
|
let outcomes = [
|
|
first_outcome.expect("first concurrent claim"),
|
|
second_outcome.expect("second concurrent claim"),
|
|
];
|
|
assert_eq!(
|
|
outcomes
|
|
.iter()
|
|
.filter(|outcome| matches!(outcome, ClaimOutcome::Joined { .. }))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
outcomes
|
|
.iter()
|
|
.filter(|outcome| matches!(outcome, ClaimOutcome::Exhausted))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(use_count(&pool, community, invite.invite_id).await, 1);
|
|
let admitted = is_relay_member(&pool, community, &first)
|
|
.await
|
|
.expect("first membership") as u8
|
|
+ is_relay_member(&pool, community, &second)
|
|
.await
|
|
.expect("second membership") as u8;
|
|
assert_eq!(admitted, 1);
|
|
delete_test_community(&pool, community).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn expiry_and_tenant_scope_return_typed_failures() {
|
|
let pool = setup_pool().await;
|
|
let community_a = make_test_community(&pool).await;
|
|
let community_b = make_test_community(&pool).await;
|
|
let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2))
|
|
.await
|
|
.expect("mint invite");
|
|
let hash = hash_v2_code(&invite.code);
|
|
|
|
assert_eq!(
|
|
claim_relay_invite(&pool, community_b, &hash, &test_pubkey(), None)
|
|
.await
|
|
.expect("cross-tenant claim"),
|
|
ClaimOutcome::Invalid
|
|
);
|
|
|
|
sqlx::query(
|
|
"UPDATE relay_invites SET expires_at = now() - interval '1 second' \
|
|
WHERE community_id = $1 AND id = $2",
|
|
)
|
|
.bind(community_a.as_uuid())
|
|
.bind(invite.invite_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("expire invite");
|
|
assert_eq!(
|
|
claim_relay_invite(&pool, community_a, &hash, &test_pubkey(), None)
|
|
.await
|
|
.expect("expired claim"),
|
|
ClaimOutcome::Expired
|
|
);
|
|
assert_eq!(use_count(&pool, community_a, invite.invite_id).await, 0);
|
|
delete_test_community(&pool, community_a).await;
|
|
delete_test_community(&pool, community_b).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn retention_sweep_deletes_only_invites_older_than_cutoff() {
|
|
let pool = setup_pool().await;
|
|
let community = make_test_community(&pool).await;
|
|
let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
|
.await
|
|
.expect("mint old invite");
|
|
let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
|
.await
|
|
.expect("mint recent invite");
|
|
let cutoff = Utc::now() - chrono::Duration::days(30);
|
|
|
|
sqlx::query("UPDATE relay_invites SET expires_at = $1 WHERE community_id = $2 AND id = $3")
|
|
.bind(cutoff - chrono::Duration::seconds(1))
|
|
.bind(community.as_uuid())
|
|
.bind(old.invite_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("age old invite");
|
|
|
|
assert_eq!(
|
|
reap_expired_relay_invites(&pool, cutoff)
|
|
.await
|
|
.expect("reap expired invites"),
|
|
1
|
|
);
|
|
let remaining: Vec<Uuid> =
|
|
sqlx::query_scalar("SELECT id FROM relay_invites WHERE community_id = $1 ORDER BY id")
|
|
.bind(community.as_uuid())
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("read remaining invites");
|
|
assert_eq!(remaining, vec![recent.invite_id]);
|
|
|
|
delete_test_community(&pool, community).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn unlimited_invites_count_each_new_member() {
|
|
let pool = setup_pool().await;
|
|
let community = make_test_community(&pool).await;
|
|
let invite = mint_relay_invite(&pool, community, "owner", 3600, None)
|
|
.await
|
|
.expect("mint unlimited invite");
|
|
let hash = hash_v2_code(&invite.code);
|
|
|
|
for (expected_count, pubkey) in [(1, test_pubkey()), (2, test_pubkey())] {
|
|
assert_eq!(
|
|
claim_relay_invite(&pool, community, &hash, &pubkey, None)
|
|
.await
|
|
.expect("unlimited claim"),
|
|
ClaimOutcome::Joined {
|
|
use_count: expected_count,
|
|
uses_remaining: None,
|
|
}
|
|
);
|
|
}
|
|
assert_eq!(use_count(&pool, community, invite.invite_id).await, 2);
|
|
delete_test_community(&pool, community).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn policy_evidence_failure_rolls_back_membership_and_consumption() {
|
|
let pool = setup_pool().await;
|
|
let community = make_test_community(&pool).await;
|
|
let pubkey = test_pubkey();
|
|
let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
|
.await
|
|
.expect("mint bounded invite");
|
|
let hash = hash_v2_code(&invite.code);
|
|
|
|
let error = claim_relay_invite(&pool, community, &hash, &pubkey, Some("too-short"))
|
|
.await
|
|
.expect_err("policy CHECK must reject an invalid version");
|
|
assert!(matches!(error, crate::DbError::Sqlx(_)), "{error:?}");
|
|
assert!(!is_relay_member(&pool, community, &pubkey)
|
|
.await
|
|
.expect("membership after rollback"));
|
|
assert_eq!(use_count(&pool, community, invite.invite_id).await, 0);
|
|
|
|
assert!(matches!(
|
|
claim_relay_invite(&pool, community, &hash, &pubkey, None)
|
|
.await
|
|
.expect("claim after rollback"),
|
|
ClaimOutcome::Joined { use_count: 1, .. }
|
|
));
|
|
delete_test_community(&pool, community).await;
|
|
}
|
|
}
|