use std::future::Future;
use crate::hashing::KeyVersion;
use crate::secret::{CodeId, ScopeKey, SubjectId};
use crate::store::error::StoreError;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CodeMeta {
pub id: CodeId,
pub key_version: KeyVersion,
pub purpose: Option<String>,
pub scope: Option<String>,
pub grant: Option<String>,
pub created_at: Option<u64>,
pub expires_at: u64,
pub used_at: Option<u64>,
pub used_by: Option<SubjectId>,
pub revoked_at: Option<u64>,
}
impl CodeMeta {
#[must_use]
pub fn is_redeemable_at(&self, now: u64) -> bool {
self.used_at.is_none() && self.revoked_at.is_none() && self.expires_at > now
}
}
#[derive(Debug, Default, Clone)]
pub struct CodeListFilter {
pub scope: Option<ScopeKey>,
pub active_only: bool,
pub limit: Option<usize>,
}
impl CodeListFilter {
#[must_use]
pub fn all() -> Self {
Self::default()
}
#[must_use]
pub fn active_in_scope(scope: ScopeKey) -> Self {
Self {
scope: Some(scope),
active_only: true,
limit: None,
}
}
}
pub trait CodeAdminStore {
fn list_codes(
&self,
filter: &CodeListFilter,
now: u64,
) -> impl Future<Output = Result<Vec<CodeMeta>, StoreError>>;
fn get_code_meta(
&self,
code_id: &CodeId,
) -> impl Future<Output = Result<Option<CodeMeta>, StoreError>>;
}
#[derive(Debug, Default, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CodeStats {
pub active: usize,
pub used: usize,
pub revoked: usize,
pub expired: usize,
}
impl CodeStats {
#[must_use]
pub fn total(&self) -> usize {
self.active + self.used + self.revoked + self.expired
}
}
#[cfg(any(test, feature = "test-utils"))]
pub mod mem_admin {
use super::*;
use crate::mem::MemCodeStore;
impl CodeAdminStore for MemCodeStore {
async fn list_codes(
&self,
filter: &CodeListFilter,
now: u64,
) -> Result<Vec<CodeMeta>, StoreError> {
let _ = (filter, now);
Ok(Vec::new())
}
async fn get_code_meta(&self, _code_id: &CodeId) -> Result<Option<CodeMeta>, StoreError> {
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_meta_is_redeemable_logic() {
let now = 1_000;
let base = CodeMeta {
id: CodeId::new("c1".into()),
key_version: crate::hashing::KeyVersion::new("v1"),
purpose: None,
scope: None,
grant: None,
created_at: Some(now - 10),
expires_at: now + 100,
used_at: None,
used_by: None,
revoked_at: None,
};
assert!(base.is_redeemable_at(now));
assert!(
!CodeMeta {
used_at: Some(now),
..base.clone()
}
.is_redeemable_at(now)
);
assert!(
!CodeMeta {
revoked_at: Some(now),
..base.clone()
}
.is_redeemable_at(now)
);
assert!(
!CodeMeta {
expires_at: now - 1,
..base
}
.is_redeemable_at(now)
);
}
#[test]
fn code_list_filter_helpers() {
let all = CodeListFilter::all();
assert!(all.scope.is_none() && !all.active_only);
let scoped = CodeListFilter::active_in_scope(ScopeKey::new("community-1"));
assert!(scoped.active_only);
assert_eq!(scoped.scope.unwrap().as_str(), "community-1");
}
#[test]
fn code_stats_total() {
let s = CodeStats {
active: 3,
used: 10,
revoked: 2,
expired: 5,
};
assert_eq!(s.total(), 20);
}
#[test]
fn code_meta_contains_no_secrets() {
let m = CodeMeta {
id: CodeId::new("c1".into()),
key_version: crate::hashing::KeyVersion::new("v1"),
purpose: Some("invite".into()),
scope: Some("community-1".into()),
grant: Some("role:member".into()),
created_at: None,
expires_at: 9_999_999,
used_at: None,
used_by: None,
revoked_at: None,
};
let dbg = format!("{m:?}");
let forbidden = ["lookup_key", "hmac", "plain_code", "secret", "pepper"];
for word in forbidden {
assert!(
!dbg.to_lowercase().contains(word),
"CodeMeta debug contains {word:?}: {dbg}"
);
}
}
}