use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::Row as _;
use crate::admin::audit::{record as audit_record, ActionType, AuditEvent, LogEntry};
use crate::admin::builtin::client_ip;
use crate::admin::redact::redact_token;
use crate::admin::Admin;
use crate::auth::recovery::{LoginThrottle, MailerEmailStatus};
use crate::auth::sessions::{hash_token_for_storage, random_token};
use crate::auth::{invalidate_sessions, set_password, SessionInvalidationReason, SessionTarget};
use crate::email::Mail;
use crate::error::Result;
use crate::http::Request;
use crate::orm::Db;
pub async fn migrate_user_lockout_schema(db: &Db) -> Result<()> {
sqlx::query(
"ALTER TABLE rustio_users \
ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0",
)
.execute(db.pool())
.await?;
sqlx::query(
"ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS last_failed_login_at TIMESTAMPTZ",
)
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ")
.execute(db.pool())
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS rustio_users_locked_until_idx \
ON rustio_users (locked_until) \
WHERE locked_until IS NOT NULL",
)
.execute(db.pool())
.await?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockState {
Unlocked,
Locked { until: DateTime<Utc> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThrottleOutcome {
Recorded { count: i32 },
JustLocked { count: i32, until: DateTime<Utc> },
Disabled { count: i32 },
}
pub async fn check_account_lockout(db: &Db, user_id: i64) -> Result<LockState> {
let row = sqlx::query("SELECT locked_until FROM rustio_users WHERE id = $1")
.bind(user_id)
.fetch_optional(db.pool())
.await?;
let Some(row) = row else {
return Ok(LockState::Unlocked);
};
let locked_until: Option<DateTime<Utc>> = row.try_get("locked_until")?;
match locked_until {
Some(until) if until > Utc::now() => Ok(LockState::Locked { until }),
_ => Ok(LockState::Unlocked),
}
}
pub async fn record_failed_login(
db: &Db,
user_id: i64,
throttle: LoginThrottle,
) -> Result<ThrottleOutcome> {
let row = sqlx::query(
"UPDATE rustio_users SET \
failed_login_count = CASE \
WHEN last_failed_login_at IS NULL \
OR last_failed_login_at < NOW() - (INTERVAL '1 minute' * $2::int) \
THEN 1 \
ELSE failed_login_count + 1 \
END, \
last_failed_login_at = NOW() \
WHERE id = $1 \
RETURNING failed_login_count",
)
.bind(user_id)
.bind(throttle.window_minutes as i32)
.fetch_one(db.pool())
.await?;
let new_count: i32 = row.try_get("failed_login_count")?;
if throttle.max_attempts == 0 {
return Ok(ThrottleOutcome::Disabled { count: new_count });
}
if (new_count as u32) < throttle.max_attempts {
return Ok(ThrottleOutcome::Recorded { count: new_count });
}
let row = sqlx::query(
"UPDATE rustio_users SET \
locked_until = NOW() + (INTERVAL '1 minute' * $2::int) \
WHERE id = $1 \
RETURNING locked_until",
)
.bind(user_id)
.bind(throttle.lock_minutes as i32)
.fetch_one(db.pool())
.await?;
let until: DateTime<Utc> = row.try_get("locked_until")?;
Ok(ThrottleOutcome::JustLocked {
count: new_count,
until,
})
}
pub async fn record_successful_login(db: &Db, user_id: i64) -> Result<()> {
sqlx::query(
"UPDATE rustio_users SET \
failed_login_count = 0, \
last_failed_login_at = NULL \
WHERE id = $1",
)
.bind(user_id)
.execute(db.pool())
.await?;
Ok(())
}
pub async fn promote_session_elevated(db: &Db, session_id: i64, ttl: ChronoDuration) -> Result<()> {
sqlx::query(
"UPDATE rustio_sessions \
SET elevated_until = NOW() + (INTERVAL '1 second' * $2::bigint), \
trust_level = 'elevated' \
WHERE session_id = $1 AND revoked_at IS NULL",
)
.bind(session_id)
.bind(ttl.num_seconds())
.execute(db.pool())
.await?;
Ok(())
}
pub async fn check_session_elevated(db: &Db, session_id: i64) -> Result<bool> {
let row = sqlx::query(
"SELECT elevated_until FROM rustio_sessions \
WHERE session_id = $1 AND revoked_at IS NULL",
)
.bind(session_id)
.fetch_optional(db.pool())
.await?;
let Some(row) = row else {
return Ok(false);
};
let elevated_until: Option<DateTime<Utc>> = row.try_get("elevated_until")?;
Ok(matches!(elevated_until, Some(t) if t > Utc::now()))
}
fn random_temp_password() -> String {
let mut bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn actor_email_fingerprint(email: &str) -> String {
let digest = Sha256::digest(email.as_bytes());
let b64 = URL_SAFE_NO_PAD.encode(digest);
b64.chars().take(8).collect()
}
async fn load_admin_reset_target(db: &Db, target_user_id: i64) -> Result<Option<(String, bool)>> {
let row = sqlx::query("SELECT email, is_active FROM rustio_users WHERE id = $1")
.bind(target_user_id)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let email: String = r.try_get("email")?;
let is_active: bool = r.try_get("is_active")?;
Ok(Some((email, is_active)))
}
None => Ok(None),
}
}
async fn update_token_mail_status(db: &Db, token_id: i64, status: &str) -> Result<()> {
sqlx::query("UPDATE rustio_password_reset_tokens SET mail_status = $1 WHERE id = $2")
.bind(status)
.bind(token_id)
.execute(db.pool())
.await?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub struct AdminActor<'a> {
pub user_id: i64,
pub email: &'a str,
}
#[allow(dead_code)] #[derive(Debug, Clone)]
pub enum AdminIssueOutcome {
Issued {
target_user_id: i64,
token_id: i64,
email_status: MailerEmailStatus,
},
UnknownTarget,
InactiveTarget,
}
#[allow(dead_code)] #[derive(Debug, Clone)]
pub enum AdminTempPwOutcome {
Set {
target_user_id: i64,
temp_password: String,
revoked_session_count: usize,
},
UnknownTarget,
InactiveTarget,
}
pub async fn issue_admin_reset_token(
db: &Db,
admin: &Admin,
request: &Request,
target_user_id: i64,
actor: AdminActor<'_>,
reason: &str,
correlation_id: Option<&str>,
) -> Result<AdminIssueOutcome> {
let (target_email, is_active) = match load_admin_reset_target(db, target_user_id).await? {
Some(v) => v,
None => return Ok(AdminIssueOutcome::UnknownTarget),
};
if !is_active {
return Ok(AdminIssueOutcome::InactiveTarget);
}
let token = random_token();
let token_hash = hash_token_for_storage(&token);
let policy = admin.active_recovery_policy();
let ttl = policy.reset_token_ttl();
let expires_at = Utc::now() + ttl;
let ip = client_ip(request);
let user_agent = request.header("user-agent").map(|s| s.to_string());
let token_id: i64 = sqlx::query_scalar(
"INSERT INTO rustio_password_reset_tokens \
(user_id, token_hash, requested_ip, requested_user_agent, \
expires_at, mail_status, correlation_id) \
VALUES ($1, $2, $3, $4, $5, 'pending', $6) \
RETURNING id",
)
.bind(target_user_id)
.bind(&token_hash)
.bind(ip.as_deref())
.bind(user_agent.as_deref())
.bind(expires_at)
.bind(correlation_id)
.fetch_one(db.pool())
.await?;
let mail_status = match policy.public_site_url(request) {
Some(public_site_url) => {
let reset_link = format!(
"{}/admin/reset-password/{}",
public_site_url.trim_end_matches('/'),
token,
);
let when = Utc::now();
let body = format!(
"An administrator at {site_header} has reset your password.\n\n\
Click the link below to set a new password:\n\n\
{reset_link}\n\n\
The link expires shortly. If you have questions, contact your \
administrator.\n",
site_header = admin.branding().site_header,
reset_link = reset_link,
);
let mail = Mail::framework_envelope(
target_email.clone(),
format!("{} — password reset", admin.branding().site_header),
body,
&admin.branding().site_header,
ip.as_deref(),
user_agent.as_deref(),
when,
);
match admin.active_mailer().send(mail).await {
Ok(()) => {
update_token_mail_status(db, token_id, "sent").await?;
MailerEmailStatus::Sent
}
Err(e) => {
log::error!(
target: "rustio_admin::recovery_admin::issue",
"mailer send failed target_user_id={} actor_user_id={} \
fingerprint={} correlation_id={:?}: {}",
target_user_id,
actor.user_id,
redact_token(&token),
correlation_id,
e,
);
update_token_mail_status(db, token_id, "failed").await?;
MailerEmailStatus::Failed
}
}
}
None => {
log::error!(
target: "rustio_admin::recovery_admin::issue",
"public_site_url derivation returned None — admin reset link \
cannot be built. target_user_id={} actor_user_id={} \
fingerprint={} correlation_id={:?}",
target_user_id,
actor.user_id,
redact_token(&token),
correlation_id,
);
update_token_mail_status(db, token_id, "failed").await?;
MailerEmailStatus::Failed
}
};
let metadata = serde_json::json!({
"actor_email_hash": actor_email_fingerprint(actor.email),
"reason": reason,
"mode": "email",
"email_send_status": match mail_status {
MailerEmailStatus::Sent => "sent",
MailerEmailStatus::Failed => "failed",
},
"must_change_password_set": false,
"token_fingerprint": redact_token(&token),
});
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::PasswordResetByOther)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary = format!(
"admin password reset (email mode); mail {}",
match mail_status {
MailerEmailStatus::Sent => "sent",
MailerEmailStatus::Failed => "failed",
}
);
audit_record(db, entry).await?;
Ok(AdminIssueOutcome::Issued {
target_user_id,
token_id,
email_status: mail_status,
})
}
pub async fn admin_set_temp_password(
db: &Db,
request: &Request,
target_user_id: i64,
actor: AdminActor<'_>,
reason: &str,
correlation_id: Option<&str>,
) -> Result<AdminTempPwOutcome> {
let (_target_email, is_active) = match load_admin_reset_target(db, target_user_id).await? {
Some(v) => v,
None => return Ok(AdminTempPwOutcome::UnknownTarget),
};
if !is_active {
return Ok(AdminTempPwOutcome::InactiveTarget);
}
let temp_password = random_temp_password();
set_password(db, target_user_id, &temp_password).await?;
sqlx::query("UPDATE rustio_users SET must_change_password = TRUE WHERE id = $1")
.bind(target_user_id)
.execute(db.pool())
.await?;
let outcome = invalidate_sessions(
db,
SessionTarget::User {
user_id: target_user_id,
},
SessionInvalidationReason::PasswordResetByOther,
)
.await?;
let revoked_count = outcome.revoked_session_ids.len();
let ip = client_ip(request);
for revoked_id in &outcome.revoked_session_ids {
let metadata = serde_json::json!({
"session_id": revoked_id,
"reason": reason,
"via": "admin_password_reset",
});
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::SessionsRevokedByOther)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary = format!("session {revoked_id} revoked by admin (temp_pw reset)");
if let Err(e) = audit_record(db, entry).await {
log::error!(
target: "rustio_admin::recovery_admin::temp_pw",
"audit::record (SessionsRevokedByOther) failed target_user_id={} \
session_id={}: {}",
target_user_id, revoked_id, e,
);
}
}
let metadata = serde_json::json!({
"actor_email_hash": actor_email_fingerprint(actor.email),
"reason": reason,
"mode": "temp_pw",
"must_change_password_set": true,
"invalidated_session_count": revoked_count,
});
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::PasswordResetByOther)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary =
format!("admin password reset (temp_pw mode); {revoked_count} session(s) revoked");
audit_record(db, entry).await?;
Ok(AdminTempPwOutcome::Set {
target_user_id,
temp_password,
revoked_session_count: revoked_count,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockDuration {
FifteenMinutes,
OneHour,
TwentyFourHours,
SevenDays,
Indefinite,
Minutes(u32),
}
impl LockDuration {
pub fn to_locked_until(self, now: DateTime<Utc>) -> DateTime<Utc> {
match self {
Self::FifteenMinutes => now + ChronoDuration::minutes(15),
Self::OneHour => now + ChronoDuration::hours(1),
Self::TwentyFourHours => now + ChronoDuration::hours(24),
Self::SevenDays => now + ChronoDuration::days(7),
Self::Indefinite => DateTime::parse_from_rfc3339("9999-12-31T23:59:59Z")
.expect("year-9999 sentinel parses")
.with_timezone(&Utc),
Self::Minutes(m) => now + ChronoDuration::minutes(i64::from(m)),
}
}
}
#[allow(dead_code)] #[derive(Debug, Clone)]
pub enum LockOutcome {
Locked {
target_user_id: i64,
until: DateTime<Utc>,
revoked_session_count: usize,
},
UnknownTarget,
}
#[allow(dead_code)] #[derive(Debug, Clone)]
pub enum UnlockOutcome {
Unlocked {
target_user_id: i64,
},
UnknownTarget,
}
#[allow(dead_code)] #[derive(Debug, Clone)]
pub enum AdminRevokeOutcome {
Revoked {
target_user_id: i64,
revoked_session_count: usize,
},
UnknownTarget,
}
pub async fn lock_user_account(
db: &Db,
request: &Request,
target_user_id: i64,
actor: AdminActor<'_>,
duration: LockDuration,
reason: &str,
correlation_id: Option<&str>,
) -> Result<LockOutcome> {
if load_admin_reset_target(db, target_user_id).await?.is_none() {
return Ok(LockOutcome::UnknownTarget);
}
let until = duration.to_locked_until(Utc::now());
sqlx::query("UPDATE rustio_users SET locked_until = $1 WHERE id = $2")
.bind(until)
.bind(target_user_id)
.execute(db.pool())
.await?;
let outcome = invalidate_sessions(
db,
SessionTarget::User {
user_id: target_user_id,
},
SessionInvalidationReason::AdministrativeRevoke,
)
.await?;
let revoked_count = outcome.revoked_session_ids.len();
let ip = client_ip(request);
for revoked_id in &outcome.revoked_session_ids {
let metadata = serde_json::json!({
"session_id": revoked_id,
"reason": reason,
"via": "manual",
});
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::SessionsRevokedByOther)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary = format!("session {revoked_id} revoked by admin (manual lock)");
if let Err(e) = audit_record(db, entry).await {
log::error!(
target: "rustio_admin::recovery_admin::lock",
"audit::record (SessionsRevokedByOther) failed target_user_id={} \
session_id={}: {}",
target_user_id, revoked_id, e,
);
}
}
let metadata = serde_json::json!({
"actor_email_hash": actor_email_fingerprint(actor.email),
"reason": reason,
"until": until,
"via": "manual",
});
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::AccountLocked)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary = format!(
"manual account lock until {}; {revoked_count} session(s) revoked",
until.to_rfc3339()
);
audit_record(db, entry).await?;
Ok(LockOutcome::Locked {
target_user_id,
until,
revoked_session_count: revoked_count,
})
}
pub async fn unlock_user_account(
db: &Db,
request: &Request,
target_user_id: i64,
actor: AdminActor<'_>,
reason: &str,
correlation_id: Option<&str>,
) -> Result<UnlockOutcome> {
if load_admin_reset_target(db, target_user_id).await?.is_none() {
return Ok(UnlockOutcome::UnknownTarget);
}
sqlx::query(
"UPDATE rustio_users SET locked_until = NULL, failed_login_count = 0 WHERE id = $1",
)
.bind(target_user_id)
.execute(db.pool())
.await?;
let metadata = serde_json::json!({
"actor_email_hash": actor_email_fingerprint(actor.email),
"reason": reason,
"via": "manual",
});
let ip = client_ip(request);
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::AccountUnlocked)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary = "manual account unlock; throttle counter cleared".to_string();
audit_record(db, entry).await?;
Ok(UnlockOutcome::Unlocked { target_user_id })
}
pub async fn admin_revoke_sessions(
db: &Db,
request: &Request,
target_user_id: i64,
actor: AdminActor<'_>,
reason: &str,
correlation_id: Option<&str>,
) -> Result<AdminRevokeOutcome> {
if load_admin_reset_target(db, target_user_id).await?.is_none() {
return Ok(AdminRevokeOutcome::UnknownTarget);
}
let outcome = invalidate_sessions(
db,
SessionTarget::User {
user_id: target_user_id,
},
SessionInvalidationReason::AdministrativeRevoke,
)
.await?;
let revoked_count = outcome.revoked_session_ids.len();
let ip = client_ip(request);
for revoked_id in &outcome.revoked_session_ids {
let metadata = serde_json::json!({
"session_id": revoked_id,
"reason": reason,
"via": "manual",
});
let mut entry = LogEntry::new(target_user_id, ActionType::Update, "users", target_user_id)
.with_event(AuditEvent::SessionsRevokedByOther)
.with_actor(actor.user_id);
entry.correlation_id = correlation_id;
entry.ip_address = ip.as_deref();
entry.metadata = Some(metadata);
entry.summary = format!("session {revoked_id} revoked by admin (manual revoke)");
if let Err(e) = audit_record(db, entry).await {
log::error!(
target: "rustio_admin::recovery_admin::revoke",
"audit::record (SessionsRevokedByOther) failed target_user_id={} \
session_id={}: {}",
target_user_id, revoked_id, e,
);
}
}
Ok(AdminRevokeOutcome::Revoked {
target_user_id,
revoked_session_count: revoked_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn throttle_outcome_variants_are_distinct() {
let now = Utc::now();
let recorded = ThrottleOutcome::Recorded { count: 3 };
let just_locked = ThrottleOutcome::JustLocked {
count: 5,
until: now,
};
let disabled = ThrottleOutcome::Disabled { count: 7 };
assert_ne!(recorded, just_locked);
assert_ne!(just_locked, disabled);
assert_ne!(recorded, disabled);
for o in [recorded, just_locked, disabled] {
match o {
ThrottleOutcome::Recorded { count } => assert!(count >= 0),
ThrottleOutcome::JustLocked { count, until: _ } => {
assert!(count > 0)
}
ThrottleOutcome::Disabled { count } => assert!(count >= 0),
}
}
}
#[test]
fn lock_state_variants_round_trip() {
let now = Utc::now();
let unlocked = LockState::Unlocked;
let locked = LockState::Locked { until: now };
assert_ne!(unlocked, locked);
fn assert_traits<T: Copy + Eq + std::fmt::Debug>() {}
assert_traits::<LockState>();
assert_traits::<ThrottleOutcome>();
}
#[test]
fn random_temp_password_is_sixteen_chars() {
for _ in 0..32 {
let pw = random_temp_password();
assert_eq!(pw.len(), 16, "temp pw {pw:?} is not 16 chars");
}
}
#[test]
fn random_temp_password_is_url_safe_base64() {
for _ in 0..32 {
let pw = random_temp_password();
assert!(
pw.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"temp pw {pw:?} has non-url-safe-base64 chars"
);
}
}
#[test]
fn random_temp_password_distinct_across_calls() {
let mut seen = std::collections::HashSet::new();
for _ in 0..64 {
let pw = random_temp_password();
assert!(seen.insert(pw), "duplicate temp pw within a small batch");
}
}
#[test]
fn actor_email_fingerprint_is_eight_chars() {
let h = actor_email_fingerprint("admin@example.com");
assert_eq!(h.len(), 8);
}
#[test]
fn actor_email_fingerprint_is_deterministic() {
let a = actor_email_fingerprint("admin@example.com");
let b = actor_email_fingerprint("admin@example.com");
assert_eq!(a, b);
}
#[test]
fn actor_email_fingerprint_differs_for_different_emails() {
let a = actor_email_fingerprint("admin@example.com");
let b = actor_email_fingerprint("user@example.com");
let c = actor_email_fingerprint("admin@example.org");
assert_ne!(a, b);
assert_ne!(a, c);
assert_ne!(b, c);
}
#[test]
fn actor_email_fingerprint_does_not_leak_plaintext() {
for input in [
"admin@example.com",
"operator@bosphorus-sham.local",
"ALICE@WORK.IO",
] {
let h = actor_email_fingerprint(input);
for window_size in 3..=input.len() {
for window in input.as_bytes().windows(window_size) {
let needle = std::str::from_utf8(window).unwrap_or("");
if needle.is_empty() {
continue;
}
assert!(
!h.contains(needle),
"fingerprint {h:?} leaks substring {needle:?} of input {input:?}"
);
}
}
}
}
#[test]
fn admin_outcome_types_are_send() {
fn assert_send<T: Send>() {}
assert_send::<AdminIssueOutcome>();
assert_send::<AdminTempPwOutcome>();
assert_send::<LockOutcome>();
assert_send::<UnlockOutcome>();
assert_send::<AdminRevokeOutcome>();
}
#[test]
fn lock_duration_presets_are_relative_to_now() {
let now = DateTime::parse_from_rfc3339("2026-05-10T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
LockDuration::FifteenMinutes.to_locked_until(now),
now + ChronoDuration::minutes(15)
);
assert_eq!(
LockDuration::OneHour.to_locked_until(now),
now + ChronoDuration::hours(1)
);
assert_eq!(
LockDuration::TwentyFourHours.to_locked_until(now),
now + ChronoDuration::hours(24)
);
assert_eq!(
LockDuration::SevenDays.to_locked_until(now),
now + ChronoDuration::days(7)
);
assert_eq!(
LockDuration::Minutes(30).to_locked_until(now),
now + ChronoDuration::minutes(30)
);
}
#[test]
fn lock_duration_indefinite_is_year_9999() {
let now = Utc::now();
let until = LockDuration::Indefinite.to_locked_until(now);
assert_eq!(until.format("%Y").to_string(), "9999");
assert!(until > now + ChronoDuration::days(365 * 100));
}
#[test]
fn lock_duration_minutes_zero_is_a_past_no_op() {
let now = Utc::now();
let until = LockDuration::Minutes(0).to_locked_until(now);
assert_eq!(until, now);
}
}