This commit is contained in:
Alexey
2026-10-19 00:15:27 +03:00
parent a99b548aa1
commit 6fdc2a5ba4
43 changed files with 2764 additions and 159 deletions
+3
View File
@@ -80,3 +80,6 @@ mod conveyor_tests;
// Host groups are validated and published as one complete immutable snapshot.
#[cfg(test)]
mod host_list_tests;
#[cfg(test)]
mod quota_period_tests;
+5
View File
@@ -84,6 +84,8 @@ pub struct HotFields {
pub user_max_tcp_conns_global_each: usize,
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
pub user_data_quota: std::collections::HashMap<String, u64>,
/// Recurring UTC calendars accepted by the active runtime source.
pub user_quota_periods: std::collections::HashMap<String, crate::config::UserQuotaPeriod>,
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
pub cidr_rate_limits: std::collections::HashMap<CidrRateLimitKey, crate::config::RateLimitBps>,
pub user_max_unique_ips: std::collections::HashMap<String, usize>,
@@ -215,6 +217,7 @@ impl HotFields {
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
user_expirations: cfg.access.user_expirations.clone(),
user_data_quota: cfg.access.user_data_quota.clone(),
user_quota_periods: cfg.access.user_quota_periods.clone(),
user_rate_limits: cfg.access.user_rate_limits.clone(),
cidr_rate_limits: cfg.access.cidr_rate_limits.clone(),
user_max_unique_ips: cfg.access.user_max_unique_ips.clone(),
@@ -228,6 +231,7 @@ impl HotFields {
pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
let mut cfg = old.clone();
cfg.quota_source_read_at = new.quota_source_read_at;
cfg.general.log_level = new.general.log_level.clone();
cfg.general.ad_tag = new.general.ad_tag.clone();
@@ -336,6 +340,7 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
cfg.access.user_expirations = new.access.user_expirations.clone();
cfg.access.user_data_quota = new.access.user_data_quota.clone();
cfg.access.user_quota_periods = new.access.user_quota_periods.clone();
cfg.access.user_rate_limits = new.access.user_rate_limits.clone();
cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.clone();
cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone();
@@ -0,0 +1,33 @@
use super::*;
#[test]
fn quota_period_reload_is_hot_and_invalid_candidates_preserve_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let source = "[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n[access.user_data_quota]\nalice = 1024\n";
std::fs::write(&path, source).unwrap();
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
let (config_tx, _config_rx) = watch::channel(initial.clone());
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
let mut state = ReloadState::new(None);
for (period, valid) in [
(Some("daily"), true),
(Some("monthly"), true),
(Some("yearly"), false),
(None, true),
] {
let suffix = period
.map(|p| format!("[access.user_quota_periods]\nalice = '{p}'\n"))
.unwrap_or_default();
std::fs::write(&path, format!("{source}{suffix}")).unwrap();
let before = config_tx.borrow().clone();
reload_config(&path, &config_tx, &log_tx, None, None, &mut state);
let after = config_tx.borrow().clone();
if valid {
let json = serde_json::to_value(after.access.user_quota_periods.get("alice")).unwrap();
assert_eq!(json, serde_json::to_value(period).unwrap());
} else {
assert!(Arc::ptr_eq(&before, &after));
}
}
}
+6
View File
@@ -463,6 +463,12 @@ pub(super) fn log_changes(
new_hot.user_expirations.len()
);
}
if old_hot.user_quota_periods != new_hot.user_quota_periods {
info!(
"config reload: user_quota_periods updated ({} entries)",
new_hot.user_quota_periods.len()
);
}
if old_hot.user_data_quota != new_hot.user_data_quota {
info!(
"config reload: user_data_quota updated ({} entries)",
+2
View File
@@ -372,6 +372,8 @@ async fn candidate_watcher_waits_for_activation_and_reconciles_disk() {
cancellation.clone(),
None,
Some(activation_rx),
None,
None,
);
let watcher = tokio::spawn(watcher);
+20 -1
View File
@@ -156,6 +156,7 @@ fn reload_config_with_resolver(
detected_ip_v6: Option<IpAddr>,
reload_state: &mut ReloadState,
dns_resolver: Option<&crate::network::dns_overrides::GenerationDnsResolver>,
refresh_quota_source: bool,
) -> Option<WatchManifest> {
let (loaded, applied_cfg) = prepared;
let LoadedConfig {
@@ -178,6 +179,9 @@ fn reload_config_with_resolver(
}
if !hot_changed {
if refresh_quota_source {
config_tx.send(Arc::new(applied_cfg)).ok();
}
reload_state.mark_applied(rendered_hash);
return Some(next_manifest);
}
@@ -228,6 +232,7 @@ pub(super) fn reload_config(
detected_ip_v6,
reload_state,
None,
false,
)
}
@@ -248,6 +253,8 @@ pub fn spawn_config_watcher(
cancellation: tokio_util::sync::CancellationToken,
dns_resolver: Option<Arc<crate::network::dns_overrides::GenerationDnsResolver>>,
mut activation: Option<watch::Receiver<bool>>,
quota_mutation: Option<Arc<tokio::sync::Mutex<()>>>,
quota_source_refresh: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
) -> (
watch::Receiver<Arc<ProxyConfig>>,
watch::Receiver<LogLevel>,
@@ -365,7 +372,12 @@ pub fn spawn_config_watcher(
if poll_watcher.is_some() {
info!("config watcher: poll watcher active (Docker/NFS safe)");
}
if initial_loaded.is_some() && !initial_matches_disk {
if initial_loaded.is_some()
&& (!initial_matches_disk
|| quota_source_refresh
.as_ref()
.is_some_and(|refresh| refresh()))
{
let _ = notify_tx.try_send(());
}
@@ -409,6 +421,8 @@ pub fn spawn_config_watcher(
&mut reload_state,
dns_resolver.clone(),
force,
quota_mutation.as_ref(),
quota_source_refresh.as_deref(),
)
.await;
if next_manifest.is_none() {
@@ -423,6 +437,8 @@ pub fn spawn_config_watcher(
&mut reload_state,
dns_resolver.clone(),
force,
quota_mutation.as_ref(),
quota_source_refresh.as_deref(),
)
.await;
}
@@ -461,3 +477,6 @@ mod path_tests {
#[cfg(test)]
mod decoy_dns_tests;
#[cfg(test)]
mod quota_period_tests;
@@ -48,6 +48,7 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
None,
&mut state,
None,
false,
)
.unwrap();
assert!(config_rx.has_changed().unwrap());
@@ -62,13 +63,14 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
None,
&mut state,
None,
false,
)
.unwrap();
assert!(!config_rx.has_changed().unwrap());
assert!(Arc::ptr_eq(&published, &config_rx.borrow()));
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
&path, &config_tx, &log_tx, None, None, &mut state, None, false, None, None,
)
.await
.unwrap();
@@ -77,7 +79,7 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
// Explicit reload bypasses the unchanged source hash and replaces the injected endpoint.
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, true,
&path, &config_tx, &log_tx, None, None, &mut state, None, true, None, None,
)
.await
.unwrap();
@@ -87,7 +89,7 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
let current = config_rx.borrow_and_update().clone();
std::fs::write(&path, SOURCE.replace("localhost:18081", "localhost:18080")).unwrap();
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
&path, &config_tx, &log_tx, None, None, &mut state, None, false, None, None,
)
.await
.unwrap();
@@ -111,7 +113,7 @@ async fn decoy_dns_unchanged_watcher_source_skips_resolution() {
let mut state = ReloadState::new(Some(source.rendered_hash));
assert!(preparation::source_matches_active(&initial, &source));
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
&path, &config_tx, &log_tx, None, None, &mut state, None, false, None, None,
)
.await
.unwrap();
@@ -34,9 +34,30 @@ pub(super) async fn reload(
reload_state: &mut ReloadState,
dns_resolver: Option<Arc<crate::network::dns_overrides::GenerationDnsResolver>>,
force: bool,
quota_mutation: Option<&Arc<tokio::sync::Mutex<()>>>,
quota_source_refresh: Option<&(dyn Fn() -> bool + Send + Sync)>,
) -> Option<WatchManifest> {
// Serialize source acquisition with API lifecycle commits, then release before DNS.
// The source timestamp fences any later mutation during asynchronous preparation.
let guard = match quota_mutation {
Some(gate) => Some(gate.lock().await),
None => None,
};
let source = read_source(path).await?;
let manifest = WatchManifest::from_source_files(&source.source_files);
// A new file edit can restore the exact pre-creation bytes. Refresh only source
// evidence for fenced accounts, retaining prepared DNS and immutable WEB state.
if !force
&& quota_source_refresh.is_some_and(|refresh| refresh())
&& source_matches_active(&config_tx.borrow(), &source)
{
let mut refreshed = (**config_tx.borrow()).clone();
refreshed.quota_source_read_at = source.config.quota_source_read_at;
config_tx.send(Arc::new(refreshed)).ok();
reload_state.mark_applied(source.rendered_hash);
return Some(manifest);
}
drop(guard);
if !force && reload_state.is_applied(source.rendered_hash) {
return Some(manifest);
}
@@ -66,6 +87,7 @@ pub(super) async fn reload(
detected_ip_v6,
reload_state,
dns_resolver.as_deref(),
quota_source_refresh.is_some_and(|refresh| refresh()),
),
Ok(Err(error)) => {
error!(
@@ -0,0 +1,105 @@
use super::*;
#[tokio::test]
async fn quota_period_unchanged_source_refreshes_only_fenced_accounts_without_dns() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n",
)
.unwrap();
let source = ProxyConfig::parse_source(&path).unwrap();
let initial = Arc::new(source.config);
let (tx, mut rx) = watch::channel(initial.clone());
let (log, _) = watch::channel(initial.general.log_level.clone());
let mut state = ReloadState::new(Some(source.rendered_hash));
let gate = Arc::new(tokio::sync::Mutex::new(()));
preparation::reload(
&path,
&tx,
&log,
None,
None,
&mut state,
None,
false,
Some(&gate),
Some(&|| true),
)
.await
.unwrap();
assert!(rx.has_changed().unwrap());
let refreshed = rx.borrow_and_update().clone();
assert!(config_equal(&initial, &refreshed));
assert!(refreshed.quota_source_read_at > initial.quota_source_read_at);
preparation::reload(
&path,
&tx,
&log,
None,
None,
&mut state,
None,
false,
Some(&gate),
Some(&|| false),
)
.await
.unwrap();
assert!(!rx.has_changed().unwrap());
assert!(Arc::ptr_eq(&refreshed, &rx.borrow()));
preparation::reload(
&path,
&tx,
&log,
None,
None,
&mut state,
None,
true,
Some(&gate),
Some(&|| true),
)
.await
.unwrap();
assert!(rx.has_changed().unwrap());
assert!(rx.borrow().quota_source_read_at > refreshed.quota_source_read_at);
assert!(config_equal(&refreshed, &rx.borrow()));
}
#[tokio::test]
async fn quota_period_watcher_initialization_refreshes_pending_identical_source() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n",
)
.unwrap();
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
let cancel = tokio_util::sync::CancellationToken::new();
let (mut rx, _, task) = spawn_config_watcher(
path,
initial.clone(),
None,
None,
cancel.clone(),
None,
None,
Some(Arc::new(tokio::sync::Mutex::new(()))),
Some(Arc::new(|| true)),
);
let task = tokio::spawn(task);
tokio::time::timeout(Duration::from_secs(5), rx.changed())
.await
.unwrap()
.unwrap();
assert!(config_equal(&initial, &rx.borrow()));
assert!(rx.borrow().quota_source_read_at > initial.quota_source_read_at);
cancel.cancel();
tokio::time::timeout(Duration::from_secs(5), task)
.await
.unwrap()
.unwrap();
}
+14 -2
View File
@@ -156,6 +156,10 @@ pub struct ProxyConfig {
#[serde(default)]
pub default_dc: Option<u8>,
/// Monotonic start of source acquisition, used only for cold quota policy fencing.
#[serde(skip)]
pub(crate) quota_source_read_at: Option<std::time::Instant>,
/// Precomputed authentication snapshot for handshake hot paths.
#[serde(skip)]
pub(crate) runtime_user_auth: Option<Arc<UserAuthSnapshot>>,
@@ -177,8 +181,11 @@ impl ProxyConfig {
path: P,
source_overrides: &BTreeMap<PathBuf, String>,
) -> Result<LoadedConfig> {
let read_at = std::time::Instant::now();
let graph = Self::read_source_graph_with_overrides(path, source_overrides)?;
Self::load_source_graph(graph)
let mut loaded = Self::load_source_graph(graph)?;
loaded.config.quota_source_read_at = Some(read_at);
Ok(loaded)
}
/// Captures the raw include graph without requiring typed config validity.
@@ -259,7 +266,11 @@ impl ProxyConfig {
path: P,
overrides: &BTreeMap<PathBuf, String>,
) -> Result<ParsedConfigSource> {
pipeline::parse_source_graph(Self::read_source_graph_with_overrides(path, overrides)?)
let read_at = std::time::Instant::now();
let mut parsed =
pipeline::parse_source_graph(Self::read_source_graph_with_overrides(path, overrides)?)?;
parsed.config.quota_source_read_at = Some(read_at);
Ok(parsed)
}
/// Loads and prepares a complete runtime candidate without blocking async workers.
@@ -318,6 +329,7 @@ impl ProxyConfig {
/// Validates cross-field configuration invariants after deserialization.
pub fn validate(&self) -> Result<()> {
self.access.validate_quota_periods()?;
if self.access.users.is_empty() {
return Err(ProxyError::Config("No users configured".to_string()));
}
+1
View File
@@ -455,6 +455,7 @@ const ACCESS_CONFIG_KEYS: &[&str] = &[
"user_max_tcp_conns_global_each",
"user_expirations",
"user_data_quota",
"user_quota_periods",
"user_rate_limits",
"cidr_rate_limits",
"user_max_unique_ips",
+1
View File
@@ -1,6 +1,7 @@
use super::*;
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
config.access.validate_quota_periods()?;
if config.general.me_writer_cmd_channel_capacity == 0 {
return Err(ProxyError::Config(
"general.me_writer_cmd_channel_capacity must be > 0".to_string(),
+2
View File
@@ -46,6 +46,8 @@ mod legacy_policy_tests;
mod me_route_tests;
#[path = "load_basic_tests/me_startup_tests.rs"]
mod me_startup_tests;
#[path = "load_basic_tests/quota_period_tests.rs"]
mod quota_period_tests;
#[path = "load_basic_tests/source_security_tests.rs"]
mod source_security_tests;
#[path = "load_basic_tests/synlimit_mss_tests.rs"]
@@ -0,0 +1,69 @@
use super::*;
#[test]
fn quota_period_configuration_survives_round_trip() {
let cfg: ProxyConfig = toml::from_str(
r#"
[access.users]
alice = "0123456789abcdef0123456789abcdef"
[access.user_data_quota]
alice = 1024
[access.user_quota_periods]
alice = "monthly"
"#,
)
.unwrap();
let encoded = serde_json::to_value(&cfg).unwrap();
assert_eq!(encoded["access"]["user_quota_periods"]["alice"], "monthly");
}
#[test]
fn quota_period_requires_a_configured_positive_quota_in_both_validation_paths() {
for tail in [
"[access.user_quota_periods]\nghost = 'daily'\n",
"[access.user_quota_periods]\nalice = 'daily'\n",
"[access.user_data_quota]\nalice = 0\n[access.user_quota_periods]\nalice = 'daily'\n",
] {
let source = format!("[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n{tail}");
let cfg: ProxyConfig = toml::from_str(&source).unwrap();
assert!(
cfg.validate()
.unwrap_err()
.to_string()
.contains("user_quota_periods")
);
assert!(load_config_error_from_temp_toml(&source).contains("user_quota_periods"));
}
for period in ["daily", "weekly", "monthly"] {
let source = format!(
"[general]\nconfig_strict = true\n[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n[access.user_data_quota]\nalice = 1024\n[access.user_quota_periods]\nalice = '{period}'\n"
);
assert_eq!(
load_config_from_temp_toml(&source)
.access
.user_quota_periods
.len(),
1
);
}
}
#[test]
fn quota_period_tokens_are_strict_and_inventory_is_bounded() {
for token in ["'Daily'", "'yearly'", "42", "true"] {
assert!(
toml::from_str::<ProxyConfig>(&format!("[access.user_quota_periods]\nalice = {token}"))
.is_err()
);
}
let mut cfg = ProxyConfig::default();
assert!(cfg.access.user_quota_periods.is_empty());
cfg.access.users = (0..65_537)
.map(|n| (format!("u{n}"), "0123456789abcdef0123456789abcdef".into()))
.collect();
cfg.access.user_data_quota.insert("u0".into(), 1);
cfg.access
.user_quota_periods
.insert("u0".into(), crate::config::UserQuotaPeriod::Daily);
assert!(cfg.validate().unwrap_err().to_string().contains("65536"));
}
+3
View File
@@ -22,6 +22,8 @@ mod links;
mod logging;
mod network;
mod policies;
// UTC quota calendar tokens have no process-local timezone dependency.
mod quota_period;
mod server;
mod web;
// WEB carrier tokens and fixed-slot policy helpers remain independent from bulky config types.
@@ -44,6 +46,7 @@ pub use policies::{
MeBindStaleMode, MeFloorMode, MeRouteNoWriterMode, MeSocksKdfPolicy, MeTelemetryLevel,
MeWriterPickMode, RstOnCloseMode, TelemetryConfig, UserMaxUniqueIpsMode,
};
pub use quota_period::UserQuotaPeriod;
#[allow(unused_imports)]
pub use server::{
CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU,
+27
View File
@@ -30,6 +30,10 @@ pub struct AccessConfig {
#[serde(default)]
pub user_data_quota: HashMap<String, u64>,
/// Optional UTC calendar for users with a positive data quota.
#[serde(default)]
pub user_quota_periods: HashMap<String, UserQuotaPeriod>,
/// Per-user transport rate limits in bits-per-second.
///
/// Each entry supports independent upload (`up_bps`) and download
@@ -90,6 +94,7 @@ impl Default for AccessConfig {
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
user_expirations: HashMap::new(),
user_data_quota: HashMap::new(),
user_quota_periods: HashMap::new(),
user_rate_limits: HashMap::new(),
cidr_rate_limits: HashMap::new(),
user_source_deny: HashMap::new(),
@@ -105,6 +110,28 @@ impl Default for AccessConfig {
}
impl AccessConfig {
/// Validates the opt-in calendar against the bounded durable quota inventory.
pub(crate) fn validate_quota_periods(&self) -> crate::error::Result<()> {
if !self.user_quota_periods.is_empty() && self.users.len() > 65_536 {
return Err(crate::error::ProxyError::Config(
"quota periods support at most 65536 configured users".to_string(),
));
}
for user in self.user_quota_periods.keys() {
if !self.users.contains_key(user)
|| !self
.user_data_quota
.get(user)
.is_some_and(|quota| *quota > 0)
{
return Err(crate::error::ProxyError::Config(format!(
"access.user_quota_periods.{user} requires an existing user with a positive data quota"
)));
}
}
Ok(())
}
pub fn is_user_enabled(&self, username: &str) -> bool {
self.user_enabled.get(username).copied().unwrap_or(true)
}
+114
View File
@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
/// Calendar used for recurring quota resets; all boundaries are UTC.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserQuotaPeriod {
/// Each UTC midnight.
Daily,
/// Monday at UTC midnight.
Weekly,
/// The first day of each UTC month.
Monthly,
}
impl UserQuotaPeriod {
/// Returns the UTC boundary at or before the supplied Unix timestamp.
pub(crate) fn start(self, epoch: u64) -> std::io::Result<u64> {
use chrono::Datelike;
let timestamp = i64::try_from(epoch).map_err(|_| invalid_time())?;
let date = chrono::DateTime::from_timestamp(timestamp, 0)
.ok_or_else(invalid_time)?
.date_naive();
let start = match self {
Self::Daily => Some(date),
Self::Weekly => date.checked_sub_days(chrono::Days::new(u64::from(
date.weekday().num_days_from_monday(),
))),
Self::Monthly => date.with_day(1),
}
.ok_or_else(invalid_time)?;
u64::try_from(
start
.and_hms_opt(0, 0, 0)
.ok_or_else(invalid_time)?
.and_utc()
.timestamp(),
)
.map_err(|_| invalid_time())
}
/// Returns the first UTC calendar boundary strictly after the cursor.
pub(crate) fn next(self, cursor: u64) -> std::io::Result<u64> {
let start = self.start(cursor)?;
let date = chrono::DateTime::from_timestamp(start as i64, 0)
.ok_or_else(invalid_time)?
.date_naive();
let next = match self {
Self::Daily => date.checked_add_days(chrono::Days::new(1)),
Self::Weekly => date.checked_add_days(chrono::Days::new(7)),
Self::Monthly => date.checked_add_months(chrono::Months::new(1)),
}
.ok_or_else(invalid_time)?;
u64::try_from(
next.and_hms_opt(0, 0, 0)
.ok_or_else(invalid_time)?
.and_utc()
.timestamp(),
)
.map_err(|_| invalid_time())
}
}
fn invalid_time() -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"quota calendar timestamp is outside the supported UTC range",
)
}
#[cfg(test)]
mod tests {
use super::*;
fn epoch(value: &str) -> u64 {
chrono::DateTime::parse_from_rfc3339(value)
.unwrap()
.timestamp() as u64
}
#[test]
fn quota_calendar_boundaries_are_strict_and_handle_leap_years() {
for (period, now, start, next) in [
(
UserQuotaPeriod::Daily,
"2024-02-29T23:59:59Z",
"2024-02-29T00:00:00Z",
"2024-03-01T00:00:00Z",
),
(
UserQuotaPeriod::Weekly,
"2024-12-31T12:00:00Z",
"2024-12-30T00:00:00Z",
"2025-01-06T00:00:00Z",
),
(
UserQuotaPeriod::Monthly,
"2024-02-15T12:00:00Z",
"2024-02-01T00:00:00Z",
"2024-03-01T00:00:00Z",
),
(
UserQuotaPeriod::Monthly,
"2023-12-31T23:59:59Z",
"2023-12-01T00:00:00Z",
"2024-01-01T00:00:00Z",
),
] {
assert_eq!(period.start(epoch(now)).unwrap(), epoch(start));
assert_eq!(period.next(epoch(now)).unwrap(), epoch(next));
assert_eq!(period.next(epoch(start)).unwrap(), epoch(next));
}
assert!(UserQuotaPeriod::Daily.next(u64::MAX).is_err());
}
}