Skip to main content

allsource_core/application/use_cases/
manage_access.rs

1use crate::application::dto::{
2    AccessTokenDto, CheckAccessRequest, CheckAccessResponse, GrantAccessResponse,
3    GrantFreeAccessRequest, ListAccessTokensResponse, RevokeAccessRequest, RevokeAccessResponse,
4};
5use crate::domain::entities::{AccessToken, AccessTokenId};
6use crate::domain::repositories::{AccessTokenRepository, ArticleRepository};
7use crate::domain::value_objects::{ArticleId, WalletAddress};
8use crate::error::Result;
9use sha2::{Digest, Sha256};
10use std::sync::Arc;
11
12/// Use Case: Grant Free Access
13///
14/// Grants free (promotional) access to an article for a reader.
15/// This is used for promotional access, reviewer access, etc.
16///
17/// Responsibilities:
18/// - Validate input
19/// - Verify article exists
20/// - Check for existing valid access
21/// - Create access token
22/// - Persist via repository
23/// - Return response with raw token
24pub struct GrantFreeAccessUseCase {
25    access_token_repository: Arc<dyn AccessTokenRepository>,
26    article_repository: Arc<dyn ArticleRepository>,
27}
28
29impl GrantFreeAccessUseCase {
30    pub fn new(
31        access_token_repository: Arc<dyn AccessTokenRepository>,
32        article_repository: Arc<dyn ArticleRepository>,
33    ) -> Self {
34        Self {
35            access_token_repository,
36            article_repository,
37        }
38    }
39
40    pub async fn execute(&self, request: GrantFreeAccessRequest) -> Result<GrantAccessResponse> {
41        // Parse IDs
42        let article_id = ArticleId::new(request.article_id)?;
43        let reader_wallet = WalletAddress::new(request.reader_wallet)?;
44        let tenant_id = crate::domain::value_objects::TenantId::new(request.tenant_id)?;
45
46        // Verify article exists
47        let article = self
48            .article_repository
49            .find_by_id(&article_id)
50            .await?
51            .ok_or_else(|| {
52                crate::error::AllSourceError::EntityNotFound("Article not found".to_string())
53            })?;
54
55        // Check for existing valid access
56        if self
57            .access_token_repository
58            .has_valid_access(&article_id, &reader_wallet)
59            .await?
60        {
61            return Err(crate::error::AllSourceError::ValidationError(
62                "Reader already has valid access to this article".to_string(),
63            ));
64        }
65
66        // Duration defaults to 30 days
67        let duration_days = request.duration_days.unwrap_or(30);
68
69        // Generate token hash
70        let raw_token = generate_raw_token(&article_id, &reader_wallet);
71        let token_hash = hash_token(&raw_token);
72
73        // Create access token
74        let access_token = AccessToken::new_free(
75            tenant_id,
76            article_id,
77            *article.creator_id(),
78            reader_wallet,
79            token_hash,
80            duration_days,
81        )?;
82
83        // Persist access token
84        self.access_token_repository.save(&access_token).await?;
85
86        Ok(GrantAccessResponse {
87            access_token: AccessTokenDto::from(&access_token),
88            raw_token,
89        })
90    }
91}
92
93/// Use Case: Check Access
94///
95/// Checks if a reader has valid access to an article.
96pub struct CheckAccessUseCase {
97    repository: Arc<dyn AccessTokenRepository>,
98}
99
100impl CheckAccessUseCase {
101    pub fn new(repository: Arc<dyn AccessTokenRepository>) -> Self {
102        Self { repository }
103    }
104
105    pub async fn execute(&self, request: CheckAccessRequest) -> Result<CheckAccessResponse> {
106        let article_id = ArticleId::new(request.article_id)?;
107        let wallet = WalletAddress::new(request.wallet_address)?;
108
109        let token = self
110            .repository
111            .find_valid_token(&article_id, &wallet)
112            .await?;
113
114        match token {
115            Some(token) => Ok(CheckAccessResponse {
116                has_access: true,
117                remaining_days: Some(token.remaining_days()),
118                access_token: Some(AccessTokenDto::from(&token)),
119            }),
120            None => Ok(CheckAccessResponse {
121                has_access: false,
122                remaining_days: None,
123                access_token: None,
124            }),
125        }
126    }
127}
128
129/// Use Case: Validate Token
130///
131/// Validates a raw access token and records access if valid.
132pub struct ValidateTokenUseCase {
133    repository: Arc<dyn AccessTokenRepository>,
134}
135
136impl ValidateTokenUseCase {
137    pub fn new(repository: Arc<dyn AccessTokenRepository>) -> Self {
138        Self { repository }
139    }
140
141    pub async fn execute(
142        &self,
143        raw_token: &str,
144        article_id: &str,
145        wallet_address: &str,
146    ) -> Result<AccessTokenDto> {
147        let article_id = ArticleId::new(article_id.to_string())?;
148        let wallet = WalletAddress::new(wallet_address.to_string())?;
149
150        // Hash the raw token
151        let token_hash = hash_token(raw_token);
152
153        // Find token by hash
154        let mut token = self
155            .repository
156            .find_by_hash(&token_hash)
157            .await?
158            .ok_or_else(|| crate::error::AllSourceError::EntityNotFound("Token not found".to_string()))?;
159
160        // Verify token grants access to this article and wallet
161        if !token.grants_access_to(&article_id, &wallet) {
162            return Err(crate::error::AllSourceError::ValidationError(
163                "Token does not grant access to this article".to_string(),
164            ));
165        }
166
167        // Record access
168        token.record_access();
169
170        // Note: In a real implementation, we would save the updated token here
171        // For now, we return the DTO with the recorded access
172
173        Ok(AccessTokenDto::from(&token))
174    }
175}
176
177/// Use Case: Revoke Access
178///
179/// Revokes an access token.
180pub struct RevokeAccessUseCase {
181    repository: Arc<dyn AccessTokenRepository>,
182}
183
184impl RevokeAccessUseCase {
185    pub fn new(repository: Arc<dyn AccessTokenRepository>) -> Self {
186        Self { repository }
187    }
188
189    pub async fn execute(&self, request: RevokeAccessRequest) -> Result<RevokeAccessResponse> {
190        let token_id = AccessTokenId::parse(&request.token_id)?;
191
192        // Find token
193        let mut token = self
194            .repository
195            .find_by_id(&token_id)
196            .await?
197            .ok_or_else(|| crate::error::AllSourceError::EntityNotFound("Token not found".to_string()))?;
198
199        // Revoke token
200        token.revoke(&request.reason)?;
201
202        // Persist via repository
203        let revoked = self.repository.revoke(&token_id, &request.reason).await?;
204
205        Ok(RevokeAccessResponse {
206            revoked,
207            access_token: AccessTokenDto::from(&token),
208        })
209    }
210}
211
212/// Use Case: Extend Access
213///
214/// Extends the duration of an access token.
215pub struct ExtendAccessUseCase;
216
217impl ExtendAccessUseCase {
218    pub fn execute(mut token: AccessToken, additional_days: i64) -> Result<AccessTokenDto> {
219        token.extend(additional_days)?;
220        Ok(AccessTokenDto::from(&token))
221    }
222}
223
224/// Use Case: Record Access
225///
226/// Records that an access token was used.
227pub struct RecordAccessUseCase;
228
229impl RecordAccessUseCase {
230    pub fn execute(mut token: AccessToken) -> AccessTokenDto {
231        token.record_access();
232        AccessTokenDto::from(&token)
233    }
234}
235
236/// Use Case: List Access Tokens
237///
238/// Returns a list of access tokens.
239pub struct ListAccessTokensUseCase;
240
241impl ListAccessTokensUseCase {
242    pub fn execute(tokens: Vec<AccessToken>) -> ListAccessTokensResponse {
243        let token_dtos: Vec<AccessTokenDto> = tokens.iter().map(AccessTokenDto::from).collect();
244        let count = token_dtos.len();
245
246        ListAccessTokensResponse {
247            tokens: token_dtos,
248            count,
249        }
250    }
251}
252
253/// Use Case: Cleanup Expired Tokens
254///
255/// Deletes expired tokens for cleanup.
256pub struct CleanupExpiredTokensUseCase {
257    repository: Arc<dyn AccessTokenRepository>,
258}
259
260impl CleanupExpiredTokensUseCase {
261    pub fn new(repository: Arc<dyn AccessTokenRepository>) -> Self {
262        Self { repository }
263    }
264
265    pub async fn execute(&self, before: chrono::DateTime<chrono::Utc>) -> Result<usize> {
266        self.repository.delete_expired(before).await
267    }
268}
269
270/// Generate a raw token for access
271fn generate_raw_token(article_id: &ArticleId, wallet: &WalletAddress) -> String {
272    use rand::Rng;
273    let random_bytes: [u8; 32] = rand::thread_rng().gen();
274    let mut hasher = Sha256::new();
275    hasher.update(article_id.to_string().as_bytes());
276    hasher.update(wallet.to_string().as_bytes());
277    hasher.update(random_bytes);
278    hasher.update(chrono::Utc::now().to_rfc3339().as_bytes());
279    format!("{:x}", hasher.finalize())
280}
281
282/// Hash a raw token for storage
283fn hash_token(raw_token: &str) -> String {
284    let mut hasher = Sha256::new();
285    hasher.update(raw_token.as_bytes());
286    format!("{:x}", hasher.finalize())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::domain::entities::PaywallArticle;
293    use crate::domain::repositories::{AccessTokenQuery, ArticleQuery};
294    use crate::domain::value_objects::{CreatorId, TenantId, TransactionId};
295    use async_trait::async_trait;
296    use chrono::{DateTime, Utc};
297    use std::sync::Mutex;
298
299    const VALID_WALLET: &str = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
300    const VALID_TOKEN_HASH: &str =
301        "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd";
302
303    struct MockAccessTokenRepository {
304        tokens: Mutex<Vec<AccessToken>>,
305    }
306
307    impl MockAccessTokenRepository {
308        fn new() -> Self {
309            Self {
310                tokens: Mutex::new(Vec::new()),
311            }
312        }
313    }
314
315    #[async_trait]
316    impl AccessTokenRepository for MockAccessTokenRepository {
317        async fn save(&self, token: &AccessToken) -> Result<()> {
318            self.tokens.lock().unwrap().push(token.clone());
319            Ok(())
320        }
321
322        async fn find_by_id(&self, id: &AccessTokenId) -> Result<Option<AccessToken>> {
323            let tokens = self.tokens.lock().unwrap();
324            Ok(tokens.iter().find(|t| t.id() == id).cloned())
325        }
326
327        async fn find_by_hash(&self, token_hash: &str) -> Result<Option<AccessToken>> {
328            let tokens = self.tokens.lock().unwrap();
329            Ok(tokens
330                .iter()
331                .find(|t| t.token_hash() == token_hash)
332                .cloned())
333        }
334
335        async fn find_by_transaction(
336            &self,
337            _transaction_id: &TransactionId,
338        ) -> Result<Option<AccessToken>> {
339            Ok(None)
340        }
341
342        async fn find_by_article_and_wallet(
343            &self,
344            article_id: &ArticleId,
345            wallet: &WalletAddress,
346        ) -> Result<Vec<AccessToken>> {
347            let tokens = self.tokens.lock().unwrap();
348            Ok(tokens
349                .iter()
350                .filter(|t| t.article_id() == article_id && t.reader_wallet() == wallet)
351                .cloned()
352                .collect())
353        }
354
355        async fn find_valid_token(
356            &self,
357            article_id: &ArticleId,
358            wallet: &WalletAddress,
359        ) -> Result<Option<AccessToken>> {
360            let tokens = self.tokens.lock().unwrap();
361            Ok(tokens
362                .iter()
363                .find(|t| {
364                    t.article_id() == article_id && t.reader_wallet() == wallet && t.is_valid()
365                })
366                .cloned())
367        }
368
369        async fn find_by_reader(
370            &self,
371            _wallet: &WalletAddress,
372            _limit: usize,
373            _offset: usize,
374        ) -> Result<Vec<AccessToken>> {
375            Ok(Vec::new())
376        }
377
378        async fn find_by_article(
379            &self,
380            _article_id: &ArticleId,
381            _limit: usize,
382            _offset: usize,
383        ) -> Result<Vec<AccessToken>> {
384            Ok(Vec::new())
385        }
386
387        async fn find_by_creator(
388            &self,
389            _creator_id: &CreatorId,
390            _limit: usize,
391            _offset: usize,
392        ) -> Result<Vec<AccessToken>> {
393            Ok(Vec::new())
394        }
395
396        async fn count(&self) -> Result<usize> {
397            Ok(self.tokens.lock().unwrap().len())
398        }
399
400        async fn count_valid(&self) -> Result<usize> {
401            Ok(0)
402        }
403
404        async fn count_by_article(&self, _article_id: &ArticleId) -> Result<usize> {
405            Ok(0)
406        }
407
408        async fn revoke(&self, _id: &AccessTokenId, _reason: &str) -> Result<bool> {
409            Ok(true)
410        }
411
412        async fn revoke_by_transaction(
413            &self,
414            _transaction_id: &TransactionId,
415            _reason: &str,
416        ) -> Result<usize> {
417            Ok(1)
418        }
419
420        async fn delete_expired(&self, _before: DateTime<Utc>) -> Result<usize> {
421            Ok(0)
422        }
423
424        async fn query(&self, _query: &AccessTokenQuery) -> Result<Vec<AccessToken>> {
425            Ok(Vec::new())
426        }
427    }
428
429    struct MockArticleRepository {
430        articles: Mutex<Vec<PaywallArticle>>,
431    }
432
433    impl MockArticleRepository {
434        fn new() -> Self {
435            Self {
436                articles: Mutex::new(Vec::new()),
437            }
438        }
439
440        fn add_article(&self, article: PaywallArticle) {
441            self.articles.lock().unwrap().push(article);
442        }
443    }
444
445    #[async_trait]
446    impl ArticleRepository for MockArticleRepository {
447        async fn save(&self, _article: &PaywallArticle) -> Result<()> {
448            Ok(())
449        }
450
451        async fn find_by_id(&self, id: &ArticleId) -> Result<Option<PaywallArticle>> {
452            let articles = self.articles.lock().unwrap();
453            Ok(articles.iter().find(|a| a.id() == id).cloned())
454        }
455
456        async fn find_by_url(&self, _url: &str) -> Result<Option<PaywallArticle>> {
457            Ok(None)
458        }
459
460        async fn find_by_creator(
461            &self,
462            _creator_id: &CreatorId,
463            _limit: usize,
464            _offset: usize,
465        ) -> Result<Vec<PaywallArticle>> {
466            Ok(Vec::new())
467        }
468
469        async fn find_by_tenant(
470            &self,
471            _tenant_id: &TenantId,
472            _limit: usize,
473            _offset: usize,
474        ) -> Result<Vec<PaywallArticle>> {
475            Ok(Vec::new())
476        }
477
478        async fn find_active_by_creator(
479            &self,
480            _creator_id: &CreatorId,
481            _limit: usize,
482            _offset: usize,
483        ) -> Result<Vec<PaywallArticle>> {
484            Ok(Vec::new())
485        }
486
487        async fn find_by_status(
488            &self,
489            _status: crate::domain::entities::ArticleStatus,
490            _limit: usize,
491            _offset: usize,
492        ) -> Result<Vec<PaywallArticle>> {
493            Ok(Vec::new())
494        }
495
496        async fn count(&self) -> Result<usize> {
497            Ok(0)
498        }
499
500        async fn count_by_creator(&self, _creator_id: &CreatorId) -> Result<usize> {
501            Ok(0)
502        }
503
504        async fn count_by_status(
505            &self,
506            _status: crate::domain::entities::ArticleStatus,
507        ) -> Result<usize> {
508            Ok(0)
509        }
510
511        async fn delete(&self, _id: &ArticleId) -> Result<bool> {
512            Ok(false)
513        }
514
515        async fn query(&self, _query: &ArticleQuery) -> Result<Vec<PaywallArticle>> {
516            Ok(Vec::new())
517        }
518
519        async fn find_top_by_revenue(
520            &self,
521            _creator_id: Option<&CreatorId>,
522            _limit: usize,
523        ) -> Result<Vec<PaywallArticle>> {
524            Ok(Vec::new())
525        }
526
527        async fn find_recent(
528            &self,
529            _creator_id: Option<&CreatorId>,
530            _limit: usize,
531        ) -> Result<Vec<PaywallArticle>> {
532            Ok(Vec::new())
533        }
534    }
535
536    #[tokio::test]
537    async fn test_grant_free_access() {
538        let token_repo = Arc::new(MockAccessTokenRepository::new());
539        let article_repo = Arc::new(MockArticleRepository::new());
540
541        // Create and add article
542        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
543        let article_id = ArticleId::new("test-article".to_string()).unwrap();
544        let creator_id = CreatorId::new();
545
546        let article = PaywallArticle::new(
547            article_id.clone(),
548            tenant_id.clone(),
549            creator_id,
550            "Test Article".to_string(),
551            "https://example.com/article".to_string(),
552            50,
553        )
554        .unwrap();
555        article_repo.add_article(article);
556
557        let use_case = GrantFreeAccessUseCase::new(token_repo.clone(), article_repo);
558
559        let request = GrantFreeAccessRequest {
560            tenant_id: "test-tenant".to_string(),
561            article_id: "test-article".to_string(),
562            reader_wallet: VALID_WALLET.to_string(),
563            duration_days: Some(7),
564            reason: Some("Promotional access".to_string()),
565        };
566
567        let response = use_case.execute(request).await;
568        assert!(response.is_ok());
569
570        let response = response.unwrap();
571        assert!(!response.raw_token.is_empty());
572        assert!(response.access_token.is_valid);
573    }
574
575    #[tokio::test]
576    async fn test_check_access_no_token() {
577        let token_repo = Arc::new(MockAccessTokenRepository::new());
578        let use_case = CheckAccessUseCase::new(token_repo);
579
580        let request = CheckAccessRequest {
581            article_id: "test-article".to_string(),
582            wallet_address: VALID_WALLET.to_string(),
583        };
584
585        let response = use_case.execute(request).await;
586        assert!(response.is_ok());
587
588        let response = response.unwrap();
589        assert!(!response.has_access);
590        assert!(response.access_token.is_none());
591    }
592
593    #[tokio::test]
594    async fn test_check_access_with_token() {
595        let token_repo = Arc::new(MockAccessTokenRepository::new());
596
597        // Add a valid token
598        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
599        let article_id = ArticleId::new("test-article".to_string()).unwrap();
600        let creator_id = CreatorId::new();
601        let wallet = WalletAddress::new(VALID_WALLET.to_string()).unwrap();
602
603        let token = AccessToken::new_free(
604            tenant_id,
605            article_id.clone(),
606            creator_id,
607            wallet.clone(),
608            VALID_TOKEN_HASH.to_string(),
609            30,
610        )
611        .unwrap();
612
613        token_repo.save(&token).await.unwrap();
614
615        let use_case = CheckAccessUseCase::new(token_repo);
616
617        let request = CheckAccessRequest {
618            article_id: "test-article".to_string(),
619            wallet_address: VALID_WALLET.to_string(),
620        };
621
622        let response = use_case.execute(request).await;
623        assert!(response.is_ok());
624
625        let response = response.unwrap();
626        assert!(response.has_access);
627        assert!(response.access_token.is_some());
628    }
629
630    #[test]
631    fn test_extend_access() {
632        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
633        let article_id = ArticleId::new("test-article".to_string()).unwrap();
634        let creator_id = CreatorId::new();
635        let wallet = WalletAddress::new(VALID_WALLET.to_string()).unwrap();
636
637        let token = AccessToken::new_free(
638            tenant_id,
639            article_id,
640            creator_id,
641            wallet,
642            VALID_TOKEN_HASH.to_string(),
643            7,
644        )
645        .unwrap();
646
647        let original_days = token.remaining_days();
648
649        let result = ExtendAccessUseCase::execute(token, 7);
650        assert!(result.is_ok());
651
652        let dto = result.unwrap();
653        assert!(dto.remaining_days >= original_days + 6); // Allow for timing variations
654    }
655
656    #[test]
657    fn test_list_access_tokens() {
658        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
659        let article_id = ArticleId::new("test-article".to_string()).unwrap();
660        let creator_id = CreatorId::new();
661        let wallet = WalletAddress::new(VALID_WALLET.to_string()).unwrap();
662
663        let tokens = vec![AccessToken::new_free(
664            tenant_id,
665            article_id,
666            creator_id,
667            wallet,
668            VALID_TOKEN_HASH.to_string(),
669            30,
670        )
671        .unwrap()];
672
673        let response = ListAccessTokensUseCase::execute(tokens);
674        assert_eq!(response.count, 1);
675        assert_eq!(response.tokens.len(), 1);
676    }
677
678    #[test]
679    fn test_token_hashing() {
680        let raw_token = "test_token_12345";
681        let hash1 = hash_token(raw_token);
682        let hash2 = hash_token(raw_token);
683
684        // Same input should produce same hash
685        assert_eq!(hash1, hash2);
686
687        // Hash should be 64 characters (SHA-256 hex)
688        assert_eq!(hash1.len(), 64);
689    }
690}