Skip to main content

allsource_core/application/use_cases/
manage_access.rs

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