use std::time::{SystemTime, UNIX_EPOCH};
use assert_matches::assert_matches;
use miden_node_db::migration::{SchemaHash, SchemaHashes};
use miden_protocol::account::AccountId;
use miden_protocol::testing::account_id::{
ACCOUNT_ID_PRIVATE_SENDER,
ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
};
use tempfile::TempDir;
use super::{
AccountAllowlist,
AllowlistError,
InvitationCode,
InvitationEntry,
InvitationImportOutcome,
InvitationStatus,
RegistrationOutcome,
};
use crate::DataDirectory;
fn setup() -> (TempDir, AccountAllowlist) {
let dir = tempfile::tempdir().unwrap();
AccountAllowlist::bootstrap(data_directory(&dir).allowlist_database_path()).unwrap();
let registry = reopen(&dir);
(dir, registry)
}
fn reopen(dir: &TempDir) -> AccountAllowlist {
AccountAllowlist::load(data_directory(dir).allowlist_database_path()).unwrap()
}
fn data_directory(dir: &TempDir) -> DataDirectory {
DataDirectory::load(dir.path().to_path_buf()).unwrap()
}
fn account(index: usize) -> AccountId {
[
ACCOUNT_ID_PRIVATE_SENDER,
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
][index]
.try_into()
.unwrap()
}
fn invitation(value: u8) -> InvitationCode {
InvitationCode::new(&format!("invitation-{value}")).unwrap()
}
fn entry(value: u8, account_id: Option<AccountId>) -> InvitationEntry {
InvitationEntry {
invitation_code: invitation(value),
account_id,
}
}
#[test]
fn migration_schema_hashes_are_stable() {
const EXPECTED: [SchemaHash; 1] = [SchemaHash::from_hex(
"e46c4bb6724dfd7b0da02c11b471ef4535bf4acc577ebe8306cbd14fd2ced399",
)];
let migrator = super::migrations::migrator().unwrap();
pretty_assertions::assert_eq!(migrator.schema_hashes(), SchemaHashes(&EXPECTED));
}
#[tokio::test]
async fn registrations_and_allowlist_timestamps_persist() {
const ALLOWLISTED_AT: i64 = 946_684_800;
let (dir, registry) = setup();
let before =
i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()).unwrap();
registry.import_invitation(entry(1, None)).await.unwrap();
registry.import_invitation(entry(2, Some(account(1)))).await.unwrap();
registry.add_account(account(2)).await.unwrap();
let after =
i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()).unwrap();
let path = data_directory(&dir).allowlist_database_path();
let timestamps = registry
.reader
.db
.read("allowlist_timestamps", |tx| {
tx.query("SELECT allowlisted_at FROM account_allowlist", &[], |row| row.get::<i64>(0))
})
.await
.unwrap();
assert_eq!(timestamps.len(), 3);
assert!(timestamps.iter().all(|timestamp| (before..=after).contains(timestamp)));
registry
.writer
.write("set_allowlist_timestamps", |tx| {
tx.execute("UPDATE account_allowlist SET allowlisted_at = ?1", &[&ALLOWLISTED_AT])
})
.await
.unwrap();
registry.register_account(invitation(1), account(0)).await.unwrap();
registry.import_invitation(entry(1, None)).await.unwrap();
registry.import_invitation(entry(2, Some(account(1)))).await.unwrap();
for index in 0..3 {
assert!(!registry.add_account(account(index)).await.unwrap());
}
drop(registry);
AccountAllowlist::migrate(&path).unwrap();
let registry = reopen(&dir);
for index in 0..3 {
assert!(registry.contains_account(account(index)).await.unwrap());
}
for (code, id) in [(1, account(0)), (2, account(1))] {
assert_eq!(
registry.invitation_status(invitation(code)).await.unwrap(),
InvitationStatus::Registered(id)
);
}
let timestamps = registry
.reader
.db
.read("allowlist_timestamps", |tx| {
tx.query("SELECT allowlisted_at FROM account_allowlist", &[], |row| row.get::<i64>(0))
})
.await
.unwrap();
assert_eq!(timestamps, vec![ALLOWLISTED_AT; 3]);
}
#[tokio::test]
async fn registration_rules() {
let (_dir, registry) = setup();
let reader = registry.reader();
assert!(!reader.contains_account(account(0)).await.unwrap());
assert_eq!(
reader.invitation_status(invitation(1)).await.unwrap(),
InvitationStatus::Unknown
);
assert_matches!(
registry.register_account(invitation(1), account(0)).await,
Err(AllowlistError::InvitationNotFound)
);
registry.import_invitation(entry(1, None)).await.unwrap();
registry.import_invitation(entry(2, None)).await.unwrap();
assert_eq!(reader.invitation_status(invitation(1)).await.unwrap(), InvitationStatus::Unused);
assert!(!reader.contains_account(account(0)).await.unwrap());
assert_eq!(
registry.register_account(invitation(1), account(0)).await.unwrap(),
RegistrationOutcome::Registered
);
assert_eq!(
registry.register_account(invitation(1), account(0)).await.unwrap(),
RegistrationOutcome::AlreadyRegistered
);
assert_matches!(
registry.register_account(invitation(1), account(1)).await,
Err(AllowlistError::InvitationAlreadyUsed)
);
assert_matches!(
registry.register_account(invitation(2), account(0)).await,
Err(AllowlistError::AccountAlreadyRegistered(id)) if id == account(0)
);
assert!(!registry.contains_account(account(1)).await.unwrap());
assert!(!registry.add_account(account(0)).await.unwrap());
assert!(registry.add_account(account(1)).await.unwrap());
assert!(!registry.add_account(account(1)).await.unwrap());
assert_matches!(
registry.register_account(invitation(2), account(1)).await,
Err(AllowlistError::AccountAlreadyRegistered(id)) if id == account(1)
);
assert_eq!(
reader.invitation_status(invitation(1)).await.unwrap(),
InvitationStatus::Registered(account(0))
);
assert_eq!(reader.invitation_status(invitation(2)).await.unwrap(), InvitationStatus::Unused);
assert!(reader.contains_account(account(0)).await.unwrap());
assert!(reader.contains_account(account(1)).await.unwrap());
}
#[tokio::test]
async fn invitation_import_preserves_registrations_on_conflicts_and_retries() {
let (_dir, registry) = setup();
assert_eq!(
registry.import_invitation(entry(1, None)).await.unwrap(),
InvitationImportOutcome {
invitation_added: true,
registered_account: None
}
);
assert_eq!(
registry.import_invitation(entry(2, Some(account(0)))).await.unwrap(),
InvitationImportOutcome {
invitation_added: true,
registered_account: Some(account(0))
}
);
assert_matches!(
registry.import_invitation(entry(2, Some(account(1)))).await,
Err(AllowlistError::InvitationAlreadyUsed)
);
for code in [1, 3] {
assert_matches!(
registry.import_invitation(entry(code, Some(account(0)))).await,
Err(AllowlistError::AccountAlreadyRegistered(id)) if id == account(0)
);
}
assert_eq!(
registry.invitation_status(invitation(1)).await.unwrap(),
InvitationStatus::Unused
);
assert_eq!(
registry.invitation_status(invitation(2)).await.unwrap(),
InvitationStatus::Registered(account(0))
);
assert_eq!(
registry.invitation_status(invitation(3)).await.unwrap(),
InvitationStatus::Unknown
);
assert!(!registry.contains_account(account(1)).await.unwrap());
assert!(!registry.contains_account(account(2)).await.unwrap());
assert_eq!(
registry.import_invitation(entry(1, Some(account(2)))).await.unwrap(),
InvitationImportOutcome {
invitation_added: false,
registered_account: Some(account(2))
}
);
assert_eq!(
registry.import_invitation(entry(3, Some(account(1)))).await.unwrap(),
InvitationImportOutcome {
invitation_added: true,
registered_account: Some(account(1))
}
);
for entry in [entry(1, Some(account(2))), entry(3, Some(account(1)))] {
assert_eq!(
registry.import_invitation(entry).await.unwrap(),
InvitationImportOutcome {
invitation_added: false,
registered_account: None
}
);
}
assert_eq!(
registry.invitation_status(invitation(1)).await.unwrap(),
InvitationStatus::Registered(account(2))
);
assert_eq!(
registry.invitation_status(invitation(3)).await.unwrap(),
InvitationStatus::Registered(account(1))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_accounts_cannot_claim_the_same_invitation() {
let (dir, registry) = setup();
registry.import_invitation(entry(1, None)).await.unwrap();
let other = reopen(&dir);
let (first, second) = tokio::join!(
registry.register_account(invitation(1), account(0)),
other.register_account(invitation(1), account(1)),
);
let winner = match (first, second) {
(Ok(RegistrationOutcome::Registered), Err(AllowlistError::InvitationAlreadyUsed)) => 0,
(Err(AllowlistError::InvitationAlreadyUsed), Ok(RegistrationOutcome::Registered)) => 1,
results => panic!("expected one successful registration, got {results:?}"),
};
assert_eq!(
registry.invitation_status(invitation(1)).await.unwrap(),
InvitationStatus::Registered(account(winner))
);
assert!(registry.contains_account(account(winner)).await.unwrap());
assert!(!registry.contains_account(account(1 - winner)).await.unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_invitations_cannot_register_the_same_account() {
let (dir, registry) = setup();
registry.import_invitation(entry(1, None)).await.unwrap();
registry.import_invitation(entry(2, None)).await.unwrap();
let other = reopen(&dir);
let (first, second) = tokio::join!(
registry.register_account(invitation(1), account(0)),
other.register_account(invitation(2), account(0)),
);
let winner = match (first, second) {
(Ok(RegistrationOutcome::Registered), Err(AllowlistError::AccountAlreadyRegistered(_))) => {
1
},
(Err(AllowlistError::AccountAlreadyRegistered(_)), Ok(RegistrationOutcome::Registered)) => {
2
},
results => panic!("expected one successful registration, got {results:?}"),
};
assert_eq!(
registry.invitation_status(invitation(winner)).await.unwrap(),
InvitationStatus::Registered(account(0))
);
assert_eq!(
registry.invitation_status(invitation(3 - winner)).await.unwrap(),
InvitationStatus::Unused
);
}
#[test]
fn invitation_codes_preserve_text_and_hide_debug_values() {
assert!(InvitationCode::new("").is_err());
let code = InvitationCode::new("code").unwrap();
for different in ["Code", " code ", "code\n"] {
assert_ne!(code, InvitationCode::new(different).unwrap());
}
assert_ne!(InvitationCode::new("\u{e9}").unwrap(), InvitationCode::new("e\u{301}").unwrap());
let invitation = InvitationCode::new("private invitation code").unwrap();
assert_eq!(
InvitationCode::from_hex_digest(&invitation.to_hex_digest()).unwrap(),
invitation
);
let debug = format!("{invitation:?}");
assert!(!debug.contains("private invitation code"));
assert!(!debug.contains(&hex::encode(invitation.digest())));
}