use std::path::Path;
use anyhow::Context as _;
use surrealdb::{
Surreal,
engine::local::{Db, Mem, SurrealKv},
types::{SurrealValue, Value},
};
use crate::base::{Res, Visibility, Void};
const NAMESPACE: &str = "conclave";
const DATABASE: &str = "conclave";
const SCHEMA: &str = "\
DEFINE INDEX IF NOT EXISTS user_username ON user FIELDS username UNIQUE;
DEFINE INDEX IF NOT EXISTS machine_pubkey ON machine FIELDS pubkey UNIQUE;
DEFINE INDEX IF NOT EXISTS machine_user_name ON machine FIELDS user, name UNIQUE;
DEFINE INDEX IF NOT EXISTS channel_name ON channel FIELDS name UNIQUE;
DEFINE INDEX IF NOT EXISTS invite_token ON invite FIELDS token UNIQUE;
DEFINE INDEX IF NOT EXISTS membership_channel_user ON membership FIELDS channel, user UNIQUE;
DEFINE INDEX IF NOT EXISTS ban_channel_user ON ban FIELDS channel, user UNIQUE;
DEFINE TABLE IF NOT EXISTS meta;
";
#[derive(Debug, Clone, PartialEq, Eq, SurrealValue)]
pub struct MetaRecord {
pub instance_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, SurrealValue)]
pub struct UserRecord {
pub username: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, SurrealValue)]
pub struct MachineRecord {
pub user: String,
pub name: String,
pub pubkey: String,
pub added_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, SurrealValue)]
pub struct ChannelRecord {
pub name: String,
pub visibility: String,
pub created_by: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, SurrealValue)]
pub struct InviteRecord {
pub channel: String,
pub token: String,
pub uses_remaining: Option<i64>,
pub expires_at: Option<String>,
pub created_by: String,
}
#[derive(SurrealValue)]
struct ByUsername {
username: String,
}
#[derive(SurrealValue)]
struct ByPubkey {
pubkey: String,
}
#[derive(SurrealValue)]
struct ByUser {
user: String,
}
#[derive(SurrealValue)]
struct ByName {
name: String,
}
#[derive(SurrealValue)]
struct ByToken {
tok: String,
}
#[derive(SurrealValue)]
struct ByUserAndName {
user: String,
name: String,
}
#[derive(SurrealValue)]
struct SetVisibility {
name: String,
visibility: String,
}
#[derive(SurrealValue)]
struct Rename {
old: String,
new: String,
}
#[derive(SurrealValue)]
struct SetUses {
tok: String,
uses: i64,
}
#[derive(SurrealValue)]
struct Membership {
channel: String,
user: String,
}
#[derive(SurrealValue)]
struct ByChannel {
channel: String,
}
const MAX_WRITE_ATTEMPTS: usize = 64;
fn is_write_conflict(err: &surrealdb::Error) -> bool {
err.to_string().to_lowercase().contains("conflict")
}
#[derive(Clone)]
pub struct Store {
db: Surreal<Db>,
}
impl Store {
pub async fn open(path: &Path) -> Res<Self> {
let db = Surreal::new::<SurrealKv>(path.to_string_lossy().as_ref()).await.context("failed to open the embedded store")?;
Self::init(db).await
}
pub async fn open_in_memory() -> Res<Self> {
let db = Surreal::new::<Mem>(()).await.context("failed to open the in-memory store")?;
Self::init(db).await
}
async fn init(db: Surreal<Db>) -> Res<Self> {
db.use_ns(NAMESPACE).use_db(DATABASE).await.context("failed to select namespace/database")?;
db.query(SCHEMA).await.context("failed to apply schema")?.check().context("schema application reported an error")?;
Ok(Self { db })
}
async fn insert<T: SurrealValue>(&self, table: &str, record: T) -> Void {
let _created: Option<Value> = self.db.create(table.to_owned()).content(record).await.with_context(|| format!("failed to insert into `{table}`"))?;
Ok(())
}
pub async fn instance_id(&self) -> Res<String> {
let mut response = self.db.query("SELECT * OMIT id FROM meta").await.context("failed to query server meta")?;
let rows: Vec<MetaRecord> = response.take(0).context("failed to decode server meta")?;
if let Some(meta) = rows.into_iter().next() {
return Ok(meta.instance_id);
}
let id = crate::identity::generate_token()?;
let created: Result<Option<MetaRecord>, _> = self.db.create(("meta", "server")).content(MetaRecord { instance_id: id.clone() }).await;
if created.is_ok() {
return Ok(id);
}
let mut response = self.db.query("SELECT * OMIT id FROM meta").await.context("failed to re-query server meta")?;
let rows: Vec<MetaRecord> = response.take(0).context("failed to decode server meta")?;
rows.into_iter().next().map(|meta| meta.instance_id).context("server meta write raced but no record exists")
}
pub async fn create_user(&self, username: &str) -> Res<UserRecord> {
let record = UserRecord {
username: username.to_owned(),
created_at: now_rfc3339(),
};
self.insert("user", record.clone()).await?;
Ok(record)
}
pub async fn get_user(&self, username: &str) -> Res<Option<UserRecord>> {
let mut response = self
.db
.query("SELECT * OMIT id FROM user WHERE username = $username")
.bind(ByUsername { username: username.to_owned() })
.await
.context("failed to query user")?;
let rows: Vec<UserRecord> = response.take(0).context("failed to decode user rows")?;
Ok(rows.into_iter().next())
}
pub async fn create_machine(&self, user: &str, name: &str, pubkey_base64: &str) -> Res<MachineRecord> {
let record = MachineRecord {
user: user.to_owned(),
name: name.to_owned(),
pubkey: pubkey_base64.to_owned(),
added_at: now_rfc3339(),
};
self.insert("machine", record.clone()).await?;
Ok(record)
}
pub async fn get_machine_by_pubkey(&self, pubkey_base64: &str) -> Res<Option<MachineRecord>> {
let mut response = self
.db
.query("SELECT * OMIT id FROM machine WHERE pubkey = $pubkey")
.bind(ByPubkey { pubkey: pubkey_base64.to_owned() })
.await
.context("failed to query machine")?;
let rows: Vec<MachineRecord> = response.take(0).context("failed to decode machine rows")?;
Ok(rows.into_iter().next())
}
pub async fn list_machines(&self, user: &str) -> Res<Vec<MachineRecord>> {
let mut response = self
.db
.query("SELECT * OMIT id FROM machine WHERE user = $user")
.bind(ByUser { user: user.to_owned() })
.await
.context("failed to list machines")?;
response.take(0).context("failed to decode machine rows")
}
pub async fn delete_machine(&self, user: &str, name: &str) -> Void {
self.db
.query("DELETE machine WHERE user = $user AND name = $name")
.bind(ByUserAndName {
user: user.to_owned(),
name: name.to_owned(),
})
.await
.context("failed to delete machine")?
.check()
.context("machine delete reported an error")?;
Ok(())
}
pub async fn create_channel(&self, name: &str, visibility: Visibility, created_by: &str) -> Res<ChannelRecord> {
let record = ChannelRecord {
name: name.to_owned(),
visibility: visibility.as_str().to_owned(),
created_by: created_by.to_owned(),
created_at: now_rfc3339(),
};
self.insert("channel", record.clone()).await?;
self.add_channel_member(name, created_by).await?;
Ok(record)
}
pub async fn get_channel(&self, name: &str) -> Res<Option<ChannelRecord>> {
let mut response = self
.db
.query("SELECT * OMIT id FROM channel WHERE name = $name")
.bind(ByName { name: name.to_owned() })
.await
.context("failed to query channel")?;
let rows: Vec<ChannelRecord> = response.take(0).context("failed to decode channel rows")?;
Ok(rows.into_iter().next())
}
pub async fn create_invite(&self, channel: &str, token: &str, uses_remaining: Option<i64>, expires_at: Option<String>, created_by: &str) -> Res<InviteRecord> {
let record = InviteRecord {
channel: channel.to_owned(),
token: token.to_owned(),
uses_remaining,
expires_at,
created_by: created_by.to_owned(),
};
self.insert("invite", record.clone()).await?;
Ok(record)
}
pub async fn get_invite(&self, token: &str) -> Res<Option<InviteRecord>> {
let mut response = self
.db
.query("SELECT * OMIT id FROM invite WHERE token = $tok")
.bind(ByToken { tok: token.to_owned() })
.await
.context("failed to query invite")?;
let rows: Vec<InviteRecord> = response.take(0).context("failed to decode invite rows")?;
Ok(rows.into_iter().next())
}
pub async fn list_invites(&self, channel: &str) -> Res<Vec<InviteRecord>> {
let mut response = self
.db
.query("SELECT * OMIT id FROM invite WHERE channel = $channel")
.bind(ByChannel { channel: channel.to_owned() })
.await
.context("failed to list invites")?;
response.take(0).context("failed to decode invite rows")
}
pub async fn list_channels(&self) -> Res<Vec<ChannelRecord>> {
let mut response = self.db.query("SELECT * OMIT id FROM channel").await.context("failed to list channels")?;
response.take(0).context("failed to decode channel rows")
}
pub async fn add_channel_member(&self, channel: &str, user: &str) -> Void {
for attempt in 0..MAX_WRITE_ATTEMPTS {
let outcome = self
.db
.query("INSERT INTO membership { channel: $channel, user: $user } ON DUPLICATE KEY UPDATE channel = $channel")
.bind(Membership {
channel: channel.to_owned(),
user: user.to_owned(),
})
.await
.and_then(surrealdb::IndexedResults::check);
match outcome {
Ok(_) => return Ok(()),
Err(e) if is_write_conflict(&e) && attempt + 1 < MAX_WRITE_ATTEMPTS => tokio::task::yield_now().await,
Err(e) => return Err(anyhow::Error::new(e).context("failed to add channel member")),
}
}
anyhow::bail!("adding a channel member exhausted {MAX_WRITE_ATTEMPTS} write-conflict retries")
}
pub async fn remove_channel_member(&self, channel: &str, user: &str) -> Void {
for attempt in 0..MAX_WRITE_ATTEMPTS {
let outcome = self
.db
.query("DELETE membership WHERE channel = $channel AND user = $user")
.bind(Membership {
channel: channel.to_owned(),
user: user.to_owned(),
})
.await
.and_then(surrealdb::IndexedResults::check);
match outcome {
Ok(_) => return Ok(()),
Err(e) if is_write_conflict(&e) && attempt + 1 < MAX_WRITE_ATTEMPTS => tokio::task::yield_now().await,
Err(e) => return Err(anyhow::Error::new(e).context("failed to remove channel member")),
}
}
anyhow::bail!("removing a channel member exhausted {MAX_WRITE_ATTEMPTS} write-conflict retries")
}
pub async fn is_channel_member(&self, channel: &str, user: &str) -> Res<bool> {
let mut response = self
.db
.query("SELECT VALUE user FROM membership WHERE channel = $channel AND user = $user")
.bind(Membership {
channel: channel.to_owned(),
user: user.to_owned(),
})
.await
.context("failed to query membership")?;
let rows: Vec<String> = response.take(0).context("failed to decode membership rows")?;
Ok(!rows.is_empty())
}
pub async fn list_user_memberships(&self, user: &str) -> Res<Vec<String>> {
let mut response = self
.db
.query("SELECT VALUE channel FROM membership WHERE user = $user")
.bind(ByUser { user: user.to_owned() })
.await
.context("failed to list user memberships")?;
response.take(0).context("failed to decode membership channels")
}
pub async fn list_channel_members(&self, channel: &str) -> Res<Vec<String>> {
let mut response = self
.db
.query("SELECT VALUE user FROM membership WHERE channel = $channel")
.bind(ByChannel { channel: channel.to_owned() })
.await
.context("failed to list channel members")?;
response.take(0).context("failed to decode membership users")
}
pub async fn add_ban(&self, channel: &str, user: &str) -> Void {
for attempt in 0..MAX_WRITE_ATTEMPTS {
let outcome = self
.db
.query("INSERT INTO ban { channel: $channel, user: $user } ON DUPLICATE KEY UPDATE channel = $channel")
.bind(Membership {
channel: channel.to_owned(),
user: user.to_owned(),
})
.await
.and_then(surrealdb::IndexedResults::check);
match outcome {
Ok(_) => return Ok(()),
Err(e) if is_write_conflict(&e) && attempt + 1 < MAX_WRITE_ATTEMPTS => tokio::task::yield_now().await,
Err(e) => return Err(anyhow::Error::new(e).context("failed to add ban")),
}
}
anyhow::bail!("adding a ban exhausted {MAX_WRITE_ATTEMPTS} write-conflict retries")
}
pub async fn remove_ban(&self, channel: &str, user: &str) -> Void {
for attempt in 0..MAX_WRITE_ATTEMPTS {
let outcome = self
.db
.query("DELETE ban WHERE channel = $channel AND user = $user")
.bind(Membership {
channel: channel.to_owned(),
user: user.to_owned(),
})
.await
.and_then(surrealdb::IndexedResults::check);
match outcome {
Ok(_) => return Ok(()),
Err(e) if is_write_conflict(&e) && attempt + 1 < MAX_WRITE_ATTEMPTS => tokio::task::yield_now().await,
Err(e) => return Err(anyhow::Error::new(e).context("failed to remove ban")),
}
}
anyhow::bail!("removing a ban exhausted {MAX_WRITE_ATTEMPTS} write-conflict retries")
}
pub async fn list_bans(&self) -> Res<Vec<(String, String)>> {
let mut response = self.db.query("SELECT channel, user FROM ban").await.context("failed to list bans")?;
let rows: Vec<Membership> = response.take(0).context("failed to decode ban rows")?;
Ok(rows.into_iter().map(|row| (row.channel, row.user)).collect())
}
pub async fn list_channel_bans(&self, channel: &str) -> Res<Vec<String>> {
let mut response = self
.db
.query("SELECT VALUE user FROM ban WHERE channel = $channel")
.bind(ByChannel { channel: channel.to_owned() })
.await
.context("failed to list channel bans")?;
response.take(0).context("failed to decode ban users")
}
pub async fn list_channels_created_by(&self, user: &str) -> Res<Vec<String>> {
let mut response = self
.db
.query("SELECT VALUE name FROM channel WHERE created_by = $user")
.bind(ByUser { user: user.to_owned() })
.await
.context("failed to list created channels")?;
response.take(0).context("failed to decode channel names")
}
pub async fn delete_user_memberships(&self, user: &str) -> Void {
self.db
.query("DELETE membership WHERE user = $user")
.bind(ByUser { user: user.to_owned() })
.await
.context("failed to delete user memberships")?
.check()
.context("user membership delete reported an error")?;
Ok(())
}
pub async fn set_channel_visibility(&self, name: &str, visibility: Visibility) -> Void {
self.db
.query("UPDATE channel SET visibility = $visibility WHERE name = $name")
.bind(SetVisibility {
name: name.to_owned(),
visibility: visibility.as_str().to_owned(),
})
.await
.context("failed to update channel visibility")?
.check()
.context("channel visibility update reported an error")?;
Ok(())
}
pub async fn rename_channel(&self, old: &str, new: &str) -> Void {
self.db
.query("UPDATE channel SET name = $new WHERE name = $old")
.bind(Rename { old: old.to_owned(), new: new.to_owned() })
.await
.context("failed to rename channel")?
.check()
.context("channel rename reported an error")?;
self.db
.query("UPDATE membership SET channel = $new WHERE channel = $old")
.bind(Rename { old: old.to_owned(), new: new.to_owned() })
.await
.context("failed to migrate channel memberships")?
.check()
.context("membership rename reported an error")?;
self.db
.query("UPDATE invite SET channel = $new WHERE channel = $old")
.bind(Rename { old: old.to_owned(), new: new.to_owned() })
.await
.context("failed to migrate channel invites")?
.check()
.context("invite rename reported an error")?;
self.db
.query("UPDATE ban SET channel = $new WHERE channel = $old")
.bind(Rename { old: old.to_owned(), new: new.to_owned() })
.await
.context("failed to migrate channel bans")?
.check()
.context("ban rename reported an error")?;
Ok(())
}
pub async fn delete_channel(&self, name: &str) -> Void {
self.db
.query("DELETE channel WHERE name = $name")
.bind(ByName { name: name.to_owned() })
.await
.context("failed to delete channel")?
.check()
.context("channel delete reported an error")?;
self.db
.query("DELETE membership WHERE channel = $channel")
.bind(ByChannel { channel: name.to_owned() })
.await
.context("failed to delete channel memberships")?
.check()
.context("membership delete reported an error")?;
self.db
.query("DELETE invite WHERE channel = $channel")
.bind(ByChannel { channel: name.to_owned() })
.await
.context("failed to delete channel invites")?
.check()
.context("invite delete reported an error")?;
self.db
.query("DELETE ban WHERE channel = $channel")
.bind(ByChannel { channel: name.to_owned() })
.await
.context("failed to delete channel bans")?
.check()
.context("ban delete reported an error")?;
Ok(())
}
pub async fn set_invite_uses(&self, token: &str, uses_remaining: i64) -> Void {
self.db
.query("UPDATE invite SET uses_remaining = $uses WHERE token = $tok")
.bind(SetUses {
tok: token.to_owned(),
uses: uses_remaining,
})
.await
.context("failed to update invite uses")?
.check()
.context("invite uses update reported an error")?;
Ok(())
}
pub async fn try_consume_invite_use(&self, token: &str) -> Res<bool> {
for attempt in 0..MAX_WRITE_ATTEMPTS {
let outcome = self
.db
.query("UPDATE invite SET uses_remaining = uses_remaining - 1 WHERE token = $tok AND uses_remaining > 0 RETURN VALUE uses_remaining")
.bind(ByToken { tok: token.to_owned() })
.await
.and_then(surrealdb::IndexedResults::check);
match outcome {
Ok(mut response) => {
let remaining: Vec<i64> = response.take(0).context("failed to decode invite uses")?;
return match remaining.into_iter().next() {
None => Ok(false),
Some(0) => {
self.delete_invite(token).await?;
Ok(true)
}
Some(_) => Ok(true),
};
}
Err(e) if is_write_conflict(&e) && attempt + 1 < MAX_WRITE_ATTEMPTS => tokio::task::yield_now().await,
Err(e) => return Err(anyhow::Error::new(e).context("failed to consume invite use")),
}
}
anyhow::bail!("consuming an invite use exhausted {MAX_WRITE_ATTEMPTS} write-conflict retries")
}
pub async fn delete_invite(&self, token: &str) -> Void {
self.db
.query("DELETE invite WHERE token = $tok")
.bind(ByToken { tok: token.to_owned() })
.await
.context("failed to delete invite")?
.check()
.context("invite delete reported an error")?;
Ok(())
}
pub async fn list_users(&self) -> Res<Vec<UserRecord>> {
let mut response = self.db.query("SELECT * OMIT id FROM user").await.context("failed to list users")?;
response.take(0).context("failed to decode user rows")
}
pub async fn delete_user(&self, username: &str) -> Void {
self.db
.query("DELETE user WHERE username = $username")
.bind(ByUsername { username: username.to_owned() })
.await
.context("failed to delete user")?
.check()
.context("user delete reported an error")?;
Ok(())
}
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use pretty_assertions::assert_eq;
async fn store() -> Store {
Store::open_in_memory().await.unwrap()
}
#[tokio::test]
async fn store_instance_id_is_stable_across_reopen() {
let dir = tempfile::TempDir::new().unwrap();
let first = {
let store = Store::open(dir.path()).await.unwrap();
let id = store.instance_id().await.unwrap();
assert!(!id.is_empty());
assert_eq!(store.instance_id().await.unwrap(), id);
id
};
let reopened = 'reopen: {
for _ in 0..50 {
if let Ok(store) = Store::open(dir.path()).await {
break 'reopen store;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
panic!("the store never became reopenable after drop");
};
assert_eq!(reopened.instance_id().await.unwrap(), first);
}
#[tokio::test]
async fn user_create_and_fetch_round_trip() {
let store = store().await;
let created = store.create_user("aaron").await.unwrap();
assert_eq!(store.get_user("aaron").await.unwrap(), Some(created));
assert_eq!(store.get_user("nobody").await.unwrap(), None);
}
#[tokio::test]
async fn duplicate_username_is_rejected() {
let store = store().await;
store.create_user("aaron").await.unwrap();
assert!(store.create_user("aaron").await.is_err(), "the unique-username constraint must reject a duplicate");
}
#[tokio::test]
async fn machine_pubkey_is_globally_unique() {
let store = store().await;
store.create_machine("aaron", "workstation", "PUBKEY-A").await.unwrap();
assert!(store.create_machine("david", "desktop", "PUBKEY-A").await.is_err());
}
#[tokio::test]
async fn machine_name_is_unique_within_a_user_but_not_across_users() {
let store = store().await;
store.create_machine("aaron", "workstation", "PUBKEY-A").await.unwrap();
assert!(store.create_machine("aaron", "workstation", "PUBKEY-B").await.is_err());
store.create_machine("david", "workstation", "PUBKEY-C").await.unwrap();
}
#[tokio::test]
async fn machines_list_and_delete_for_a_user() {
let store = store().await;
store.create_machine("aaron", "workstation", "PUBKEY-A").await.unwrap();
store.create_machine("aaron", "sno-box", "PUBKEY-B").await.unwrap();
assert_eq!(store.list_machines("aaron").await.unwrap().len(), 2);
store.delete_machine("aaron", "sno-box").await.unwrap();
let remaining = store.list_machines("aaron").await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].name, "workstation");
}
#[tokio::test]
async fn channel_create_fetch_and_unique_name() {
let store = store().await;
let created = store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
assert_eq!(created.visibility, "private");
assert_eq!(store.get_channel("ops").await.unwrap(), Some(created));
assert!(store.create_channel("ops", Visibility::Public, "david").await.is_err());
}
#[tokio::test]
async fn invite_create_fetch_and_unique_token() {
let store = store().await;
let created = store.create_invite("ops", "tok-123", Some(5), None, "aaron").await.unwrap();
assert_eq!(store.get_invite("tok-123").await.unwrap(), Some(created));
assert!(store.create_invite("ops", "tok-123", None, None, "aaron").await.is_err());
}
#[tokio::test]
async fn channel_membership_add_remove_and_list() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
assert!(store.is_channel_member("ops", "aaron").await.unwrap());
store.add_channel_member("ops", "david").await.unwrap();
store.add_channel_member("ops", "david").await.unwrap();
assert!(store.is_channel_member("ops", "david").await.unwrap());
let mut members = store.list_channel_members("ops").await.unwrap();
members.sort();
assert_eq!(members, vec!["aaron".to_owned(), "david".to_owned()]);
assert_eq!(store.list_user_memberships("david").await.unwrap(), vec!["ops".to_owned()]);
store.remove_channel_member("ops", "david").await.unwrap();
assert!(!store.is_channel_member("ops", "david").await.unwrap());
}
#[tokio::test]
async fn channel_memberships_follow_delete_and_rename() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
store.add_channel_member("ops", "david").await.unwrap();
store.rename_channel("ops", "operations").await.unwrap();
assert!(store.is_channel_member("operations", "david").await.unwrap());
assert!(!store.is_channel_member("ops", "david").await.unwrap());
store.delete_channel("operations").await.unwrap();
assert!(store.list_channel_members("operations").await.unwrap().is_empty());
}
#[tokio::test]
async fn channel_visibility_can_be_changed() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
store.set_channel_visibility("ops", Visibility::Public).await.unwrap();
assert_eq!(store.get_channel("ops").await.unwrap().unwrap().visibility, "public");
}
#[tokio::test]
async fn channel_rename_moves_the_record_and_respects_uniqueness() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
store.create_channel("taken", Visibility::Public, "aaron").await.unwrap();
store.rename_channel("ops", "operations").await.unwrap();
assert!(store.get_channel("ops").await.unwrap().is_none());
assert!(store.get_channel("operations").await.unwrap().is_some());
assert!(store.rename_channel("operations", "taken").await.is_err());
}
#[tokio::test]
async fn channel_can_be_deleted_and_listed() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
store.create_channel("lobby", Visibility::Public, "aaron").await.unwrap();
assert_eq!(store.list_channels().await.unwrap().len(), 2);
store.delete_channel("ops").await.unwrap();
let remaining = store.list_channels().await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].name, "lobby");
}
#[tokio::test]
async fn invite_uses_can_be_decremented_and_revoked() {
let store = store().await;
store.create_invite("ops", "tok-123", Some(5), None, "aaron").await.unwrap();
store.set_invite_uses("tok-123", 4).await.unwrap();
assert_eq!(store.get_invite("tok-123").await.unwrap().unwrap().uses_remaining, Some(4));
store.delete_invite("tok-123").await.unwrap();
assert!(store.get_invite("tok-123").await.unwrap().is_none());
}
#[tokio::test]
async fn invites_are_dropped_when_the_channel_is_deleted() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
store.create_invite("ops", "tok", Some(5), None, "aaron").await.unwrap();
store.delete_channel("ops").await.unwrap();
assert!(
store.get_invite("tok").await.unwrap().is_none(),
"deleting a channel must drop its invites so a future same-named channel cannot honor them"
);
}
#[tokio::test]
async fn invites_follow_a_channel_rename() {
let store = store().await;
store.create_channel("ops", Visibility::Private, "aaron").await.unwrap();
store.create_invite("ops", "tok", None, None, "aaron").await.unwrap();
store.rename_channel("ops", "operations").await.unwrap();
assert_eq!(store.get_invite("tok").await.unwrap().unwrap().channel, "operations", "an invite must follow its renamed channel");
}
#[tokio::test]
async fn ban_add_remove_and_list_round_trip() {
let store = store().await;
store.create_channel("ops", Visibility::Public, "aaron").await.unwrap();
store.add_ban("ops", "bob").await.unwrap();
store.add_ban("ops", "bob").await.unwrap(); store.add_ban("ops", "mallory").await.unwrap();
let mut bans = store.list_bans().await.unwrap();
bans.sort();
assert_eq!(bans, vec![("ops".to_owned(), "bob".to_owned()), ("ops".to_owned(), "mallory".to_owned())]);
store.remove_ban("ops", "bob").await.unwrap();
assert_eq!(store.list_bans().await.unwrap(), vec![("ops".to_owned(), "mallory".to_owned())]);
}
#[tokio::test]
async fn bans_follow_delete_and_rename() {
let store = store().await;
store.create_channel("ops", Visibility::Public, "aaron").await.unwrap();
store.add_ban("ops", "bob").await.unwrap();
store.rename_channel("ops", "operations").await.unwrap();
assert_eq!(store.list_bans().await.unwrap(), vec![("operations".to_owned(), "bob".to_owned())]);
store.delete_channel("operations").await.unwrap();
assert!(store.list_bans().await.unwrap().is_empty(), "deleting a channel must drop its bans");
}
#[tokio::test]
async fn users_can_be_listed_and_deleted() {
let store = store().await;
store.create_user("aaron").await.unwrap();
store.create_user("david").await.unwrap();
assert_eq!(store.list_users().await.unwrap().len(), 2);
store.delete_user("david").await.unwrap();
let remaining = store.list_users().await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].username, "aaron");
}
}