bindizr-service 0.1.0-beta.4

Application services for bindizr zone, record, token, and notification workflows
Documentation
use chrono::{Duration, Utc};
use rand::{RngExt, distr::Alphanumeric};
use sha2::{Digest, Sha256};

use super::{error::ServiceError, repository::RepositoryService};
use crate::model::api_token::ApiToken;

pub struct TokenService;

pub(crate) fn hash_token(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    hex::encode(hasher.finalize())
}

impl TokenService {
    pub async fn create_token(
        description: Option<&str>,
        expires_in_days: Option<i64>,
    ) -> Result<ApiToken, ServiceError> {
        validate_expires_in_days(expires_in_days)?;

        let raw_token: String = rand::rng()
            .sample_iter(Alphanumeric)
            .take(32)
            .map(char::from)
            .collect();

        let token_hash = hash_token(&raw_token);

        let expires_at = expires_in_days.map(|days| Utc::now() + Duration::days(days));

        let mut created = RepositoryService::create_api_token(ApiToken {
            id: 0,
            token: token_hash,
            description: description.map(|d| d.to_string()),
            expires_at,
            created_at: Utc::now(),
            last_used_at: None,
        })
        .await?;

        created.token = raw_token;
        Ok(created)
    }

    pub async fn list_tokens() -> Result<Vec<ApiToken>, ServiceError> {
        let mut tokens = RepositoryService::get_all_api_tokens().await?;
        for token in &mut tokens {
            token.token.clear();
        }
        Ok(tokens)
    }

    pub async fn delete_token(token_id: i32) -> Result<(), ServiceError> {
        let exists = RepositoryService::get_api_token_by_id(token_id).await?;
        if exists.is_none() {
            return Err(ServiceError::NotFound("Token not found".to_string()));
        }

        RepositoryService::delete_api_token(token_id).await
    }
}

fn validate_expires_in_days(expires_in_days: Option<i64>) -> Result<(), ServiceError> {
    if let Some(days) = expires_in_days
        && days <= 0
    {
        return Err(ServiceError::BadRequest(
            "expires_in_days must be greater than 0".to_string(),
        ));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::validate_expires_in_days;
    use crate::error::ServiceError;

    #[test]
    fn validate_expires_in_days_accepts_none_and_positive_values() {
        validate_expires_in_days(None).unwrap();
        validate_expires_in_days(Some(1)).unwrap();
    }

    #[test]
    fn validate_expires_in_days_rejects_non_positive_values() {
        let zero = validate_expires_in_days(Some(0)).unwrap_err();
        let negative = validate_expires_in_days(Some(-1)).unwrap_err();

        assert!(matches!(zero, ServiceError::BadRequest(_)));
        assert!(matches!(negative, ServiceError::BadRequest(_)));
    }
}