Skip to main content

guts_compat/
token.rs

1//! Personal Access Token types and authentication.
2
3use argon2::{
4    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
5    Argon2,
6};
7use rand::Rng;
8use serde::{Deserialize, Serialize};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::error::{CompatError, Result};
12use crate::user::UserId;
13
14/// Unique identifier for a token.
15pub type TokenId = u64;
16
17/// Token format: guts_<prefix>_<secret>
18/// Prefix: 8 lowercase alphanumeric characters
19/// Secret: 32 alphanumeric characters (mixed case)
20const TOKEN_PREFIX_LEN: usize = 8;
21const TOKEN_SECRET_LEN: usize = 32;
22
23/// A personal access token for authentication.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PersonalAccessToken {
26    /// Unique token ID.
27    pub id: TokenId,
28    /// User who owns this token.
29    pub user_id: UserId,
30    /// User-provided name/description.
31    pub name: String,
32    /// Argon2id hash of the full token (prefix + secret).
33    pub token_hash: String,
34    /// First 8 characters for quick lookup.
35    pub token_prefix: String,
36    /// Scopes granted to this token.
37    pub scopes: Vec<TokenScope>,
38    /// Optional expiration timestamp.
39    pub expires_at: Option<u64>,
40    /// Last time the token was used.
41    pub last_used_at: Option<u64>,
42    /// When the token was created.
43    pub created_at: u64,
44}
45
46impl PersonalAccessToken {
47    /// Generate a new token with a random value.
48    ///
49    /// Returns the token struct and the plaintext token value (only shown once).
50    pub fn generate(
51        id: TokenId,
52        user_id: UserId,
53        name: String,
54        scopes: Vec<TokenScope>,
55        expires_at: Option<u64>,
56    ) -> Result<(Self, String)> {
57        let token_value = TokenValue::generate();
58        let plaintext = token_value.to_string();
59
60        // Hash the secret part
61        let token_hash = hash_token_secret(&token_value.secret)?;
62
63        let now = SystemTime::now()
64            .duration_since(UNIX_EPOCH)
65            .unwrap()
66            .as_secs();
67
68        let token = Self {
69            id,
70            user_id,
71            name,
72            token_hash,
73            token_prefix: token_value.prefix,
74            scopes,
75            expires_at,
76            last_used_at: None,
77            created_at: now,
78        };
79
80        Ok((token, plaintext))
81    }
82
83    /// Verify a token secret against the stored hash.
84    pub fn verify(&self, secret: &str) -> Result<()> {
85        verify_token_secret(secret, &self.token_hash)
86    }
87
88    /// Check if the token is expired.
89    pub fn is_expired(&self) -> bool {
90        if let Some(expires_at) = self.expires_at {
91            let now = SystemTime::now()
92                .duration_since(UNIX_EPOCH)
93                .unwrap()
94                .as_secs();
95            now >= expires_at
96        } else {
97            false
98        }
99    }
100
101    /// Check if the token has a specific scope.
102    pub fn has_scope(&self, required: TokenScope) -> bool {
103        // Admin scope grants all permissions
104        if self.scopes.contains(&TokenScope::Admin) {
105            return true;
106        }
107
108        // Check for exact scope match
109        if self.scopes.contains(&required) {
110            return true;
111        }
112
113        // Check for parent scope (e.g., RepoWrite includes RepoRead)
114        match required {
115            TokenScope::RepoRead => {
116                self.scopes.contains(&TokenScope::RepoWrite)
117                    || self.scopes.contains(&TokenScope::RepoAdmin)
118            }
119            TokenScope::RepoWrite => self.scopes.contains(&TokenScope::RepoAdmin),
120            TokenScope::UserRead => self.scopes.contains(&TokenScope::UserWrite),
121            TokenScope::OrgRead => {
122                self.scopes.contains(&TokenScope::OrgWrite)
123                    || self.scopes.contains(&TokenScope::OrgAdmin)
124            }
125            TokenScope::OrgWrite => self.scopes.contains(&TokenScope::OrgAdmin),
126            TokenScope::SshKeyRead => self.scopes.contains(&TokenScope::SshKeyWrite),
127            TokenScope::WorkflowRead => self.scopes.contains(&TokenScope::WorkflowWrite),
128            TokenScope::WebhookRead => self.scopes.contains(&TokenScope::WebhookWrite),
129            _ => false,
130        }
131    }
132
133    /// Update the last_used_at timestamp.
134    pub fn touch(&mut self) {
135        self.last_used_at = Some(
136            SystemTime::now()
137                .duration_since(UNIX_EPOCH)
138                .unwrap()
139                .as_secs(),
140        );
141    }
142
143    /// Convert to a response (without the hash).
144    pub fn to_response(&self, plaintext: Option<&str>) -> TokenResponse {
145        TokenResponse {
146            id: self.id,
147            name: self.name.clone(),
148            scopes: self.scopes.clone(),
149            token_prefix: self.token_prefix.clone(),
150            token: plaintext.map(|s| s.to_string()),
151            expires_at: self.expires_at.map(format_timestamp),
152            last_used_at: self.last_used_at.map(format_timestamp),
153            created_at: format_timestamp(self.created_at),
154        }
155    }
156}
157
158/// Token scopes for fine-grained permissions.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum TokenScope {
162    // Repository
163    /// Read access to repositories.
164    RepoRead,
165    /// Write (push) access to repositories.
166    RepoWrite,
167    /// Admin access to repositories (settings, collaborators).
168    RepoAdmin,
169    /// Delete repositories.
170    RepoDelete,
171
172    // User
173    /// Read user profile.
174    UserRead,
175    /// Update user profile.
176    UserWrite,
177    /// Access email addresses.
178    UserEmail,
179
180    // Organization
181    /// Read organization info.
182    OrgRead,
183    /// Manage organization (members, teams).
184    OrgWrite,
185    /// Admin organization operations.
186    OrgAdmin,
187
188    // SSH Keys
189    /// List SSH keys.
190    SshKeyRead,
191    /// Add/remove SSH keys.
192    SshKeyWrite,
193
194    // Workflow (CI/CD)
195    /// Read workflows and runs.
196    WorkflowRead,
197    /// Trigger and manage workflows.
198    WorkflowWrite,
199
200    // Webhooks
201    /// Read webhooks.
202    WebhookRead,
203    /// Manage webhooks.
204    WebhookWrite,
205
206    // Admin (superuser)
207    /// Full admin access (all permissions).
208    Admin,
209}
210
211impl TokenScope {
212    /// Get all available scopes.
213    pub fn all() -> Vec<Self> {
214        vec![
215            Self::RepoRead,
216            Self::RepoWrite,
217            Self::RepoAdmin,
218            Self::RepoDelete,
219            Self::UserRead,
220            Self::UserWrite,
221            Self::UserEmail,
222            Self::OrgRead,
223            Self::OrgWrite,
224            Self::OrgAdmin,
225            Self::SshKeyRead,
226            Self::SshKeyWrite,
227            Self::WorkflowRead,
228            Self::WorkflowWrite,
229            Self::WebhookRead,
230            Self::WebhookWrite,
231            Self::Admin,
232        ]
233    }
234
235    /// Get the display name for this scope.
236    pub fn display_name(&self) -> &'static str {
237        match self {
238            Self::RepoRead => "repo:read",
239            Self::RepoWrite => "repo:write",
240            Self::RepoAdmin => "repo:admin",
241            Self::RepoDelete => "repo:delete",
242            Self::UserRead => "user:read",
243            Self::UserWrite => "user:write",
244            Self::UserEmail => "user:email",
245            Self::OrgRead => "org:read",
246            Self::OrgWrite => "org:write",
247            Self::OrgAdmin => "org:admin",
248            Self::SshKeyRead => "ssh_key:read",
249            Self::SshKeyWrite => "ssh_key:write",
250            Self::WorkflowRead => "workflow:read",
251            Self::WorkflowWrite => "workflow:write",
252            Self::WebhookRead => "webhook:read",
253            Self::WebhookWrite => "webhook:write",
254            Self::Admin => "admin",
255        }
256    }
257}
258
259/// The plaintext token value (prefix + secret).
260#[derive(Debug, Clone)]
261pub struct TokenValue {
262    /// First 8 characters for lookup.
263    pub prefix: String,
264    /// Secret part (32 characters).
265    pub secret: String,
266}
267
268impl TokenValue {
269    /// Generate a new random token value.
270    pub fn generate() -> Self {
271        let mut rng = rand::thread_rng();
272
273        // Generate prefix (lowercase alphanumeric)
274        let prefix: String = (0..TOKEN_PREFIX_LEN)
275            .map(|_| {
276                let idx = rng.gen_range(0..36);
277                if idx < 10 {
278                    (b'0' + idx) as char
279                } else {
280                    (b'a' + idx - 10) as char
281                }
282            })
283            .collect();
284
285        // Generate secret (mixed case alphanumeric)
286        let secret: String = (0..TOKEN_SECRET_LEN)
287            .map(|_| {
288                let idx = rng.gen_range(0..62);
289                if idx < 10 {
290                    (b'0' + idx) as char
291                } else if idx < 36 {
292                    (b'a' + idx - 10) as char
293                } else {
294                    (b'A' + idx - 36) as char
295                }
296            })
297            .collect();
298
299        Self { prefix, secret }
300    }
301
302    /// Parse a token string into prefix and secret.
303    pub fn parse(token: &str) -> Result<Self> {
304        // Format: guts_<prefix>_<secret>
305        let parts: Vec<&str> = token.split('_').collect();
306        if parts.len() != 3 || parts[0] != "guts" {
307            return Err(CompatError::InvalidTokenFormat);
308        }
309
310        let prefix = parts[1];
311        let secret = parts[2];
312
313        if prefix.len() != TOKEN_PREFIX_LEN || secret.len() != TOKEN_SECRET_LEN {
314            return Err(CompatError::InvalidTokenFormat);
315        }
316
317        Ok(Self {
318            prefix: prefix.to_string(),
319            secret: secret.to_string(),
320        })
321    }
322}
323
324impl std::fmt::Display for TokenValue {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        write!(f, "guts_{}_{}", self.prefix, self.secret)
327    }
328}
329
330/// Hash a token secret using Argon2id.
331fn hash_token_secret(secret: &str) -> Result<String> {
332    let salt = SaltString::generate(&mut OsRng);
333    let argon2 = Argon2::default();
334
335    argon2
336        .hash_password(secret.as_bytes(), &salt)
337        .map(|hash| hash.to_string())
338        .map_err(|e| CompatError::Crypto(e.to_string()))
339}
340
341/// Verify a token secret against a hash.
342fn verify_token_secret(secret: &str, hash: &str) -> Result<()> {
343    let parsed_hash = PasswordHash::new(hash).map_err(|e| CompatError::Crypto(e.to_string()))?;
344
345    Argon2::default()
346        .verify_password(secret.as_bytes(), &parsed_hash)
347        .map_err(|_| CompatError::InvalidToken)
348}
349
350/// Token response for API.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct TokenResponse {
353    /// Token ID.
354    pub id: TokenId,
355    /// User-provided name.
356    pub name: String,
357    /// Granted scopes.
358    pub scopes: Vec<TokenScope>,
359    /// Token prefix for identification.
360    pub token_prefix: String,
361    /// Full token (only included on creation).
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub token: Option<String>,
364    /// Expiration timestamp.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub expires_at: Option<String>,
367    /// Last used timestamp.
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub last_used_at: Option<String>,
370    /// Creation timestamp.
371    pub created_at: String,
372}
373
374/// Request to create a new token.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct CreateTokenRequest {
377    /// Name/description for the token.
378    pub name: String,
379    /// Scopes to grant.
380    pub scopes: Vec<TokenScope>,
381    /// Optional expiration in days.
382    #[serde(default)]
383    pub expires_in_days: Option<u32>,
384}
385
386/// Format a Unix timestamp as ISO 8601.
387fn format_timestamp(timestamp: u64) -> String {
388    let secs_per_day = 86400;
389    let secs_per_hour = 3600;
390    let secs_per_min = 60;
391
392    let mut days = timestamp / secs_per_day;
393    let remaining = timestamp % secs_per_day;
394    let hours = remaining / secs_per_hour;
395    let remaining = remaining % secs_per_hour;
396    let minutes = remaining / secs_per_min;
397    let seconds = remaining % secs_per_min;
398
399    let mut year = 1970;
400    loop {
401        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
402        if days < days_in_year {
403            break;
404        }
405        days -= days_in_year;
406        year += 1;
407    }
408
409    let days_in_month = if is_leap_year(year) {
410        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
411    } else {
412        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
413    };
414
415    let mut month = 0;
416    for (i, &dim) in days_in_month.iter().enumerate() {
417        if days < dim as u64 {
418            month = i + 1;
419            break;
420        }
421        days -= dim as u64;
422    }
423    let day = days + 1;
424
425    format!(
426        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
427        year, month, day, hours, minutes, seconds
428    )
429}
430
431fn is_leap_year(year: u64) -> bool {
432    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_token_generation() {
441        let (token, plaintext) =
442            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoRead], None)
443                .unwrap();
444
445        assert_eq!(token.id, 1);
446        assert_eq!(token.user_id, 1);
447        assert_eq!(token.name, "test");
448        assert!(plaintext.starts_with("guts_"));
449
450        // Verify the token
451        let parsed = TokenValue::parse(&plaintext).unwrap();
452        assert!(token.verify(&parsed.secret).is_ok());
453    }
454
455    #[test]
456    fn test_token_value_format() {
457        let token = TokenValue::generate();
458        let s = token.to_string();
459
460        assert!(s.starts_with("guts_"));
461        let parts: Vec<&str> = s.split('_').collect();
462        assert_eq!(parts.len(), 3);
463        assert_eq!(parts[0], "guts");
464        assert_eq!(parts[1].len(), 8);
465        assert_eq!(parts[2].len(), 32);
466    }
467
468    #[test]
469    fn test_token_parse() {
470        let token = TokenValue::generate();
471        let s = token.to_string();
472        let parsed = TokenValue::parse(&s).unwrap();
473
474        assert_eq!(parsed.prefix, token.prefix);
475        assert_eq!(parsed.secret, token.secret);
476    }
477
478    #[test]
479    fn test_token_parse_invalid() {
480        assert!(TokenValue::parse("invalid").is_err());
481        assert!(TokenValue::parse("guts_short_secret").is_err());
482        assert!(TokenValue::parse("github_abc12345_12345678901234567890123456789012").is_err());
483    }
484
485    #[test]
486    fn test_token_parse_wrong_prefix() {
487        // Wrong starting word
488        assert!(TokenValue::parse("github_abc12345_12345678901234567890123456789012").is_err());
489        assert!(TokenValue::parse("pat_12345678_12345678901234567890123456789012").is_err());
490    }
491
492    #[test]
493    fn test_token_parse_wrong_part_count() {
494        // Too few parts
495        assert!(TokenValue::parse("guts_12345678901234567890123456789012").is_err());
496        // Too many parts
497        assert!(TokenValue::parse("guts_abc12345_12345678901234567890123456789012_extra").is_err());
498    }
499
500    #[test]
501    fn test_token_parse_wrong_prefix_length() {
502        // Prefix too short
503        assert!(TokenValue::parse("guts_abc_12345678901234567890123456789012").is_err());
504        // Prefix too long
505        assert!(TokenValue::parse("guts_abc123456789_12345678901234567890123456789012").is_err());
506    }
507
508    #[test]
509    fn test_token_parse_wrong_secret_length() {
510        // Secret too short
511        assert!(TokenValue::parse("guts_abc12345_short").is_err());
512        // Secret too long
513        assert!(
514            TokenValue::parse("guts_abc12345_123456789012345678901234567890123456789012345")
515                .is_err()
516        );
517    }
518
519    #[test]
520    fn test_token_expiration() {
521        let now = SystemTime::now()
522            .duration_since(UNIX_EPOCH)
523            .unwrap()
524            .as_secs();
525
526        let (mut token, _) =
527            PersonalAccessToken::generate(1, 1, "test".into(), vec![], Some(now - 1)).unwrap();
528        assert!(token.is_expired());
529
530        token.expires_at = Some(now + 3600);
531        assert!(!token.is_expired());
532
533        token.expires_at = None;
534        assert!(!token.is_expired());
535    }
536
537    #[test]
538    fn test_token_expiration_boundary() {
539        let now = SystemTime::now()
540            .duration_since(UNIX_EPOCH)
541            .unwrap()
542            .as_secs();
543
544        // Exactly at expiration time should be expired
545        let (token, _) =
546            PersonalAccessToken::generate(1, 1, "test".into(), vec![], Some(now)).unwrap();
547        assert!(token.is_expired());
548    }
549
550    #[test]
551    fn test_token_scope_hierarchy() {
552        let (token, _) =
553            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoAdmin], None)
554                .unwrap();
555
556        assert!(token.has_scope(TokenScope::RepoAdmin));
557        assert!(token.has_scope(TokenScope::RepoWrite));
558        assert!(token.has_scope(TokenScope::RepoRead));
559        assert!(!token.has_scope(TokenScope::OrgRead));
560    }
561
562    #[test]
563    fn test_token_scope_user_hierarchy() {
564        let (token, _) =
565            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::UserWrite], None)
566                .unwrap();
567
568        assert!(token.has_scope(TokenScope::UserWrite));
569        assert!(token.has_scope(TokenScope::UserRead));
570        assert!(!token.has_scope(TokenScope::UserEmail));
571    }
572
573    #[test]
574    fn test_token_scope_org_hierarchy() {
575        let (token, _) =
576            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::OrgAdmin], None)
577                .unwrap();
578
579        assert!(token.has_scope(TokenScope::OrgAdmin));
580        assert!(token.has_scope(TokenScope::OrgWrite));
581        assert!(token.has_scope(TokenScope::OrgRead));
582    }
583
584    #[test]
585    fn test_token_scope_ssh_hierarchy() {
586        let (token, _) =
587            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::SshKeyWrite], None)
588                .unwrap();
589
590        assert!(token.has_scope(TokenScope::SshKeyWrite));
591        assert!(token.has_scope(TokenScope::SshKeyRead));
592    }
593
594    #[test]
595    fn test_token_scope_workflow_hierarchy() {
596        let (token, _) = PersonalAccessToken::generate(
597            1,
598            1,
599            "test".into(),
600            vec![TokenScope::WorkflowWrite],
601            None,
602        )
603        .unwrap();
604
605        assert!(token.has_scope(TokenScope::WorkflowWrite));
606        assert!(token.has_scope(TokenScope::WorkflowRead));
607    }
608
609    #[test]
610    fn test_token_scope_webhook_hierarchy() {
611        let (token, _) = PersonalAccessToken::generate(
612            1,
613            1,
614            "test".into(),
615            vec![TokenScope::WebhookWrite],
616            None,
617        )
618        .unwrap();
619
620        assert!(token.has_scope(TokenScope::WebhookWrite));
621        assert!(token.has_scope(TokenScope::WebhookRead));
622    }
623
624    #[test]
625    fn test_admin_scope_grants_all() {
626        let (token, _) =
627            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::Admin], None)
628                .unwrap();
629
630        assert!(token.has_scope(TokenScope::RepoRead));
631        assert!(token.has_scope(TokenScope::UserWrite));
632        assert!(token.has_scope(TokenScope::OrgAdmin));
633        assert!(token.has_scope(TokenScope::Admin));
634    }
635
636    #[test]
637    fn test_scope_display_names() {
638        assert_eq!(TokenScope::RepoRead.display_name(), "repo:read");
639        assert_eq!(TokenScope::Admin.display_name(), "admin");
640    }
641
642    #[test]
643    fn test_all_scopes() {
644        let scopes = TokenScope::all();
645        assert_eq!(scopes.len(), 17);
646        assert!(scopes.contains(&TokenScope::RepoRead));
647        assert!(scopes.contains(&TokenScope::Admin));
648    }
649
650    #[test]
651    fn test_all_scope_display_names() {
652        // Every scope should have a unique display name
653        let scopes = TokenScope::all();
654        let display_names: Vec<_> = scopes.iter().map(|s| s.display_name()).collect();
655        let unique: std::collections::HashSet<_> = display_names.iter().collect();
656        assert_eq!(unique.len(), scopes.len());
657    }
658
659    #[test]
660    fn test_token_verify_wrong_secret() {
661        let (token, _plaintext) =
662            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoRead], None)
663                .unwrap();
664
665        // Verify with wrong secret should fail
666        assert!(token.verify("wrongsecret").is_err());
667        assert!(token.verify("12345678901234567890123456789012").is_err());
668    }
669
670    #[test]
671    fn test_token_touch() {
672        let (mut token, _) =
673            PersonalAccessToken::generate(1, 1, "test".into(), vec![], None).unwrap();
674
675        assert!(token.last_used_at.is_none());
676        token.touch();
677        assert!(token.last_used_at.is_some());
678    }
679
680    #[test]
681    fn test_token_to_response() {
682        let (token, plaintext) = PersonalAccessToken::generate(
683            1,
684            1,
685            "My Token".into(),
686            vec![TokenScope::RepoRead],
687            None,
688        )
689        .unwrap();
690
691        // With plaintext
692        let response = token.to_response(Some(&plaintext));
693        assert_eq!(response.id, 1);
694        assert_eq!(response.name, "My Token");
695        assert!(response.token.is_some());
696        assert_eq!(response.token.as_ref().unwrap(), &plaintext);
697
698        // Without plaintext
699        let response = token.to_response(None);
700        assert!(response.token.is_none());
701    }
702
703    #[test]
704    fn test_token_response_timestamps() {
705        let (token, _) = PersonalAccessToken::generate(1, 1, "test".into(), vec![], None).unwrap();
706
707        let response = token.to_response(None);
708        assert!(!response.created_at.is_empty());
709        assert!(response.created_at.contains('T'));
710        assert!(response.created_at.ends_with('Z'));
711    }
712
713    #[test]
714    fn test_token_uniqueness() {
715        // Generate multiple tokens and ensure they're unique
716        let mut tokens = Vec::new();
717        for _ in 0..10 {
718            let token = TokenValue::generate();
719            tokens.push(token.to_string());
720        }
721
722        let unique: std::collections::HashSet<_> = tokens.iter().collect();
723        assert_eq!(unique.len(), tokens.len());
724    }
725
726    #[test]
727    fn test_token_prefix_format() {
728        // Generate multiple tokens and verify prefix format
729        for _ in 0..10 {
730            let token = TokenValue::generate();
731            // Prefix should be lowercase alphanumeric
732            assert!(token
733                .prefix
734                .chars()
735                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
736            assert_eq!(token.prefix.len(), 8);
737        }
738    }
739
740    #[test]
741    fn test_token_secret_format() {
742        // Generate multiple tokens and verify secret format
743        for _ in 0..10 {
744            let token = TokenValue::generate();
745            // Secret should be alphanumeric (mixed case)
746            assert!(token.secret.chars().all(|c| c.is_ascii_alphanumeric()));
747            assert_eq!(token.secret.len(), 32);
748        }
749    }
750
751    #[test]
752    fn test_token_scope_no_read_without_write() {
753        // RepoWrite grants RepoRead
754        let (token, _) =
755            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoWrite], None)
756                .unwrap();
757
758        assert!(token.has_scope(TokenScope::RepoRead));
759        assert!(token.has_scope(TokenScope::RepoWrite));
760        assert!(!token.has_scope(TokenScope::RepoAdmin));
761    }
762
763    #[test]
764    fn test_token_scope_exact_match() {
765        // Token with only RepoRead should only have RepoRead
766        let (token, _) =
767            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoRead], None)
768                .unwrap();
769
770        assert!(token.has_scope(TokenScope::RepoRead));
771        assert!(!token.has_scope(TokenScope::RepoWrite));
772        assert!(!token.has_scope(TokenScope::RepoAdmin));
773    }
774
775    #[test]
776    fn test_token_multiple_scopes() {
777        let (token, _) = PersonalAccessToken::generate(
778            1,
779            1,
780            "test".into(),
781            vec![TokenScope::RepoRead, TokenScope::UserRead],
782            None,
783        )
784        .unwrap();
785
786        assert!(token.has_scope(TokenScope::RepoRead));
787        assert!(token.has_scope(TokenScope::UserRead));
788        assert!(!token.has_scope(TokenScope::RepoWrite));
789        assert!(!token.has_scope(TokenScope::UserWrite));
790    }
791
792    #[test]
793    fn test_format_timestamp_epoch() {
794        let ts = format_timestamp(0);
795        assert_eq!(ts, "1970-01-01T00:00:00Z");
796    }
797
798    #[test]
799    fn test_format_timestamp_2024() {
800        let ts = format_timestamp(1704067200);
801        assert_eq!(ts, "2024-01-01T00:00:00Z");
802    }
803
804    #[test]
805    fn test_token_scope_delete() {
806        // RepoDelete is standalone
807        let (token, _) =
808            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoDelete], None)
809                .unwrap();
810
811        assert!(token.has_scope(TokenScope::RepoDelete));
812        assert!(!token.has_scope(TokenScope::RepoRead));
813        assert!(!token.has_scope(TokenScope::RepoWrite));
814    }
815
816    #[test]
817    fn test_token_scope_email() {
818        // UserEmail is standalone
819        let (token, _) =
820            PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::UserEmail], None)
821                .unwrap();
822
823        assert!(token.has_scope(TokenScope::UserEmail));
824        assert!(!token.has_scope(TokenScope::UserRead));
825    }
826}
827
828#[cfg(test)]
829mod proptests {
830    use super::*;
831    use proptest::prelude::*;
832
833    proptest! {
834        /// Property: Token generation always produces valid parseable tokens
835        #[test]
836        fn prop_token_generation_parseable(
837            id in 0u64..1000,
838            user_id in 0u64..1000,
839            name in "[a-zA-Z0-9 ]{1,50}"
840        ) {
841            let (_, plaintext) = PersonalAccessToken::generate(
842                id,
843                user_id,
844                name,
845                vec![TokenScope::RepoRead],
846                None,
847            ).unwrap();
848
849            let parsed = TokenValue::parse(&plaintext);
850            prop_assert!(parsed.is_ok());
851        }
852
853        /// Property: Token verification succeeds with correct secret
854        #[test]
855        fn prop_token_verification_correct(
856            id in 0u64..100,
857            user_id in 0u64..100
858        ) {
859            let (token, plaintext) = PersonalAccessToken::generate(
860                id,
861                user_id,
862                "test".to_string(),
863                vec![TokenScope::RepoRead],
864                None,
865            ).unwrap();
866
867            let parsed = TokenValue::parse(&plaintext).unwrap();
868            let result = token.verify(&parsed.secret);
869            prop_assert!(result.is_ok());
870        }
871
872        /// Property: Token verification fails with wrong secret
873        #[test]
874        fn prop_token_verification_wrong_secret(
875            wrong_secret in "[a-zA-Z0-9]{32}"
876        ) {
877            let (token, plaintext) = PersonalAccessToken::generate(
878                1,
879                1,
880                "test".to_string(),
881                vec![TokenScope::RepoRead],
882                None,
883            ).unwrap();
884
885            let parsed = TokenValue::parse(&plaintext).unwrap();
886
887            // Only test if the wrong secret is actually different
888            if wrong_secret != parsed.secret {
889                let result = token.verify(&wrong_secret);
890                prop_assert!(result.is_err());
891            }
892        }
893
894        /// Property: Admin scope grants all other scopes
895        #[test]
896        fn prop_admin_grants_all(_seed in 0u32..100) {
897            let (token, _) = PersonalAccessToken::generate(
898                1,
899                1,
900                "test".to_string(),
901                vec![TokenScope::Admin],
902                None,
903            ).unwrap();
904
905            for scope in TokenScope::all() {
906                prop_assert!(token.has_scope(scope), "Admin should grant {:?}", scope);
907            }
908        }
909
910        /// Property: Token prefix is always 8 lowercase alphanumeric chars
911        #[test]
912        fn prop_token_prefix_format(_seed in 0u32..100) {
913            let token = TokenValue::generate();
914            prop_assert_eq!(token.prefix.len(), 8);
915            prop_assert!(token.prefix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
916        }
917
918        /// Property: Token secret is always 32 alphanumeric chars
919        #[test]
920        fn prop_token_secret_format(_seed in 0u32..100) {
921            let token = TokenValue::generate();
922            prop_assert_eq!(token.secret.len(), 32);
923            prop_assert!(token.secret.chars().all(|c| c.is_ascii_alphanumeric()));
924        }
925
926        /// Property: Tokens are always unique
927        #[test]
928        fn prop_token_uniqueness(_seed in 0u32..100) {
929            let token1 = TokenValue::generate();
930            let token2 = TokenValue::generate();
931            // Extremely unlikely to be the same
932            prop_assert!(token1.to_string() != token2.to_string());
933        }
934
935        /// Property: Invalid token formats are always rejected
936        #[test]
937        fn prop_invalid_token_rejected(s in ".*") {
938            // Unless it happens to be a valid format (extremely unlikely)
939            if !s.starts_with("guts_") || s.split('_').count() != 3 {
940                let result = TokenValue::parse(&s);
941                prop_assert!(result.is_err());
942            }
943        }
944    }
945}