Files
buzz/crates/buzz-proxy/src/invite_store.rs
Will Pfleger d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00

142 lines
3.7 KiB
Rust

//! Thread-safe in-memory invite token registry.
use chrono::Utc;
use dashmap::DashMap;
use uuid::Uuid;
use crate::error::ProxyError;
use crate::invite::InviteToken;
/// In-memory invite token store backed by DashMap.
pub struct InviteStore {
tokens: DashMap<String, InviteToken>,
}
impl InviteStore {
/// Create an empty store.
pub fn new() -> Self {
Self {
tokens: DashMap::new(),
}
}
/// Insert a new invite token.
pub fn insert(&self, token: InviteToken) {
self.tokens.insert(token.token.clone(), token);
}
/// Validate a token string. Returns the list of channel UUIDs if valid.
pub fn validate(&self, token_str: &str) -> Result<Vec<Uuid>, ProxyError> {
let entry = self
.tokens
.get(token_str)
.ok_or(ProxyError::InviteNotFound)?;
entry.validate(Utc::now())?;
Ok(entry.channel_ids.clone())
}
/// Atomically validate and consume a token (increment use count).
/// Returns the list of channel UUIDs if valid.
pub fn validate_and_consume(&self, token_str: &str) -> Result<Vec<Uuid>, ProxyError> {
let mut entry = self
.tokens
.get_mut(token_str)
.ok_or(ProxyError::InviteNotFound)?;
entry.validate(Utc::now())?;
let channels = entry.channel_ids.clone();
entry.consume();
Ok(channels)
}
/// Remove a token from the store.
pub fn remove(&self, token_str: &str) -> Option<InviteToken> {
self.tokens.remove(token_str).map(|(_, v)| v)
}
/// Number of tokens in the store.
pub fn len(&self) -> usize {
self.tokens.len()
}
/// Whether the store is empty.
pub fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
}
impl Default for InviteStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn future_token(token: &str, channels: Vec<Uuid>, max_uses: u32) -> InviteToken {
InviteToken::new(token, channels, Utc::now() + Duration::hours(1), max_uses)
}
fn expired_token(token: &str) -> InviteToken {
InviteToken::new(token, vec![], Utc::now() - Duration::seconds(1), 5)
}
#[test]
fn validate_returns_channels() {
let store = InviteStore::new();
let ch1 = Uuid::new_v4();
let ch2 = Uuid::new_v4();
store.insert(future_token("tok-1", vec![ch1, ch2], 5));
let channels = store.validate("tok-1").unwrap();
assert_eq!(channels, vec![ch1, ch2]);
}
#[test]
fn validate_not_found() {
let store = InviteStore::new();
assert!(matches!(
store.validate("nonexistent"),
Err(ProxyError::InviteNotFound)
));
}
#[test]
fn validate_expired() {
let store = InviteStore::new();
store.insert(expired_token("tok-expired"));
assert!(matches!(
store.validate("tok-expired"),
Err(ProxyError::InviteExpired)
));
}
#[test]
fn validate_and_consume_increments() {
let store = InviteStore::new();
store.insert(future_token("tok-use", vec![], 2));
store.validate_and_consume("tok-use").unwrap();
store.validate_and_consume("tok-use").unwrap();
// Third use should fail
assert!(matches!(
store.validate_and_consume("tok-use"),
Err(ProxyError::InviteExhausted)
));
}
#[test]
fn remove_works() {
let store = InviteStore::new();
store.insert(future_token("tok-rm", vec![], 5));
assert_eq!(store.len(), 1);
let removed = store.remove("tok-rm");
assert!(removed.is_some());
assert_eq!(store.len(), 0);
}
}