diff --git a/Cargo.lock b/Cargo.lock index cc3fb96..e780731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,9 +141,11 @@ dependencies = [ "chrono", "common", "jsonwebtoken", + "reqwest", "serde", "serde_json", "thiserror 1.0.69", + "urlencoding", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index a9e7443..efbad76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,3 +48,5 @@ bcrypt = "0.15" dotenvy = "0.15" tower-http = { version = "0.5", features = ["cors", "trace"] } async-trait = "0.1" +urlencoding = "2.1" + diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index b4d2640..d686cee 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -7,14 +7,12 @@ use serde_json::{json, Value}; pub fn create_router() -> Router { Router::new() .route("/health", get(health_check)) - // --- V1 Routes --- .route("/api/v1/auth/login", post(login_v1)) .route("/api/v1/repositories", get(list_repositories_v1)) .route("/api/v1/resumes/generate", post(generate_resume_v1)) .route("/api/v1/ats/score", post(ats_score_v1)) .route("/api/v1/analytics/overview", get(analytics_v1)) - // --- V2 Enterprise Routes --- .route("/api/v2/search/hybrid", post(hybrid_search_v2)) .route("/api/v2/career/insights", get(career_insights_v2)) diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index 8dfe6f4..800e203 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -5,4 +5,6 @@ pub mod provider; pub mod rag; pub mod tokenizer; -pub use provider::{AIProvider, ChatMessage, ClaudeProvider, GeminiProvider, OllamaProvider, OpenAIProvider}; +pub use provider::{ + AIProvider, ChatMessage, ClaudeProvider, GeminiProvider, OllamaProvider, OpenAIProvider, +}; diff --git a/crates/ai/src/provider.rs b/crates/ai/src/provider.rs index 21fe031..e7af8df 100644 --- a/crates/ai/src/provider.rs +++ b/crates/ai/src/provider.rs @@ -77,7 +77,10 @@ pub struct OllamaProvider { #[async_trait] impl AIProvider for OllamaProvider { async fn generate(&self, prompt: &str) -> Result { - Ok(format!("[Ollama Local Response] Processed prompt: {}", prompt)) + Ok(format!( + "[Ollama Local Response] Processed prompt: {}", + prompt + )) } async fn embeddings(&self, _text: &str) -> Result> { Ok(vec![0.05; 1536]) diff --git a/crates/ai/src/rag.rs b/crates/ai/src/rag.rs index 8a6ed41..c22ec8a 100644 --- a/crates/ai/src/rag.rs +++ b/crates/ai/src/rag.rs @@ -1,3 +1,7 @@ pub fn build_rag_context(retrieved_chunks: &[String], user_query: &str) -> String { - format!("Context:\n{}\n\nUser Question: {}", retrieved_chunks.join("\n"), user_query) + format!( + "Context:\n{}\n\nUser Question: {}", + retrieved_chunks.join("\n"), + user_query + ) } diff --git a/crates/ats/src/lib.rs b/crates/ats/src/lib.rs index e9a7e4d..7b58eeb 100644 --- a/crates/ats/src/lib.rs +++ b/crates/ats/src/lib.rs @@ -20,7 +20,11 @@ pub fn analyze_resume_ats(resume_text: &str) -> AtsScoreResult { AtsScoreResult { overall_score: score.min(100), - keyword_matches: vec!["Rust".to_string(), "PostgreSQL".to_string(), "API".to_string()], + keyword_matches: vec![ + "Rust".to_string(), + "PostgreSQL".to_string(), + "API".to_string(), + ], formatting_score: 95, suggestions, } diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index 5629cca..97db314 100644 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -14,3 +14,6 @@ uuid.workspace = true chrono.workspace = true thiserror.workspace = true axum.workspace = true +reqwest.workspace = true +urlencoding.workspace = true + diff --git a/crates/auth/src/jwt.rs b/crates/auth/src/jwt.rs index 921c70f..fd9b1f1 100644 --- a/crates/auth/src/jwt.rs +++ b/crates/auth/src/jwt.rs @@ -3,16 +3,31 @@ use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation} use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Claims { pub sub: String, pub email: String, pub exp: usize, + pub iat: usize, } -pub fn create_jwt(user_id: Uuid, email: &str, secret: &str) -> Result { - let expiration = Utc::now() - .checked_add_signed(Duration::days(7)) +pub fn create_jwt( + user_id: Uuid, + email: &str, + secret: &str, +) -> Result { + create_jwt_with_ttl(user_id, email, secret, Duration::days(7)) +} + +pub fn create_jwt_with_ttl( + user_id: Uuid, + email: &str, + secret: &str, + ttl: Duration, +) -> Result { + let now = Utc::now(); + let expiration = now + .checked_add_signed(ttl) .expect("valid timestamp") .timestamp() as usize; @@ -20,9 +35,14 @@ pub fn create_jwt(user_id: Uuid, email: &str, secret: &str) -> Result Result { @@ -34,3 +54,73 @@ pub fn verify_jwt(token: &str, secret: &str) -> Result = token.split('.').collect(); + assert_eq!(parts.len(), 3); + + // Tamper with payload + let tampered_payload = format!("{}.eyJzdWIiOiJoYWNrZXIifQ.{}", parts[0], parts[2]); + let result = verify_jwt(&tampered_payload, secret); + assert!(result.is_err()); + } +} diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index a5d1bfa..241f0cc 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -1,21 +1,22 @@ pub mod api_keys; pub mod jwt; +pub mod middleware; pub mod oauth; +pub mod passwords; pub mod permissions; pub mod rbac; pub mod refresh_tokens; pub use api_keys::generate_api_key; -pub use jwt::{create_jwt, verify_jwt, Claims}; -pub use oauth::validate_oauth_provider; -pub use permissions::check_permission; +pub use jwt::{create_jwt, create_jwt_with_ttl, verify_jwt, Claims}; +pub use middleware::{AuthError, AuthUser}; +pub use oauth::{ + validate_oauth_provider, GitHubOAuthClient, GitHubOAuthError, GitHubUserProfile, + GoogleOAuthClient, GoogleUserProfile, OAuthCallbackQuery, OAuthSessionResult, OAuthState, +}; +pub use passwords::{hash_password, verify_password}; +pub use permissions::{ + check_permission, get_permissions_for_role, role_has_permission, Permission, +}; pub use rbac::{has_role, Role}; pub use refresh_tokens::generate_refresh_token; - -pub fn hash_password(password: &str) -> Result { - bcrypt::hash(password, bcrypt::DEFAULT_COST) -} - -pub fn verify_password(password: &str, hash: &str) -> Result { - bcrypt::verify(password, hash) -} diff --git a/crates/auth/src/middleware.rs b/crates/auth/src/middleware.rs new file mode 100644 index 0000000..8bcaa73 --- /dev/null +++ b/crates/auth/src/middleware.rs @@ -0,0 +1,70 @@ +use crate::jwt::{verify_jwt, Claims}; +use axum::{ + async_trait, + extract::FromRequestParts, + http::{header, request::Parts, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: String, + pub email: String, +} + +pub struct AuthError(pub StatusCode, pub String); + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let body = Json(json!({ + "error": self.1, + "status": self.0.as_u16(), + })); + (self.0, body).into_response() + } +} + +#[async_trait] +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let auth_header = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|val| val.to_str().ok()) + .ok_or_else(|| { + AuthError( + StatusCode::UNAUTHORIZED, + "Missing Authorization header".to_string(), + ) + })?; + + let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + AuthError( + StatusCode::UNAUTHORIZED, + "Invalid Authorization scheme".to_string(), + ) + })?; + + let jwt_secret = std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "devresume_jwt_secret_key_32_chars_min".to_string()); + + let claims: Claims = verify_jwt(token, &jwt_secret).map_err(|_| { + AuthError( + StatusCode::UNAUTHORIZED, + "Invalid or expired JWT token".to_string(), + ) + })?; + + Ok(AuthUser { + id: claims.sub, + email: claims.email, + }) + } +} diff --git a/crates/auth/src/oauth.rs b/crates/auth/src/oauth.rs deleted file mode 100644 index cffd61f..0000000 --- a/crates/auth/src/oauth.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub fn validate_oauth_provider(provider: &str) -> bool { - matches!(provider, "github" | "google" | "linkedin") -} diff --git a/crates/auth/src/oauth/callback.rs b/crates/auth/src/oauth/callback.rs new file mode 100644 index 0000000..a54a238 --- /dev/null +++ b/crates/auth/src/oauth/callback.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct OAuthCallbackQuery { + pub code: String, + pub state: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthSessionResult { + pub user_id: String, + pub email: String, + pub username: String, + pub avatar_url: String, + pub access_token: String, + pub refresh_token: String, +} diff --git a/crates/auth/src/oauth/github.rs b/crates/auth/src/oauth/github.rs new file mode 100644 index 0000000..56d756e --- /dev/null +++ b/crates/auth/src/oauth/github.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GitHubOAuthError { + #[error("Failed to build HTTP client: {0}")] + ClientBuild(reqwest::Error), + #[error("Token exchange failed: {0}")] + TokenExchange(reqwest::Error), + #[error("User fetch failed: {0}")] + UserFetch(reqwest::Error), + #[error("GitHub API returned error: {0}")] + ApiError(String), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GitHubTokenResponse { + pub access_token: String, + pub token_type: String, + pub scope: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubUserProfile { + pub id: u64, + pub login: String, + pub name: Option, + pub email: Option, + pub avatar_url: String, + pub bio: Option, + pub location: Option, + pub html_url: String, +} + +pub struct GitHubOAuthClient { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, +} + +impl GitHubOAuthClient { + pub fn new(client_id: String, client_secret: String, redirect_uri: String) -> Self { + Self { + client_id, + client_secret, + redirect_uri, + } + } + + pub fn get_authorization_url(&self, state: &str) -> String { + format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=user:email,read:user&state={}", + urlencoding::encode(&self.client_id), + urlencoding::encode(&self.redirect_uri), + urlencoding::encode(state) + ) + } + + pub async fn exchange_code(&self, code: &str) -> Result { + let client = reqwest::Client::builder() + .user_agent("DevResume-AI") + .build() + .map_err(GitHubOAuthError::ClientBuild)?; + + let params = [ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("code", code), + ("redirect_uri", self.redirect_uri.as_str()), + ]; + + let response = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .form(¶ms) + .send() + .await + .map_err(GitHubOAuthError::TokenExchange)?; + + if !response.status().is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(GitHubOAuthError::ApiError(text)); + } + + let token_resp = response + .json::() + .await + .map_err(GitHubOAuthError::TokenExchange)?; + + Ok(token_resp) + } + + pub async fn get_user_profile( + &self, + access_token: &str, + ) -> Result { + let client = reqwest::Client::builder() + .user_agent("DevResume-AI") + .build() + .map_err(GitHubOAuthError::ClientBuild)?; + + let response = client + .get("https://api.github.com/user") + .bearer_auth(access_token) + .send() + .await + .map_err(GitHubOAuthError::UserFetch)?; + + if !response.status().is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(GitHubOAuthError::ApiError(text)); + } + + let profile = response + .json::() + .await + .map_err(GitHubOAuthError::UserFetch)?; + + Ok(profile) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_authorization_url_generation() { + let client = GitHubOAuthClient::new( + "client_123".to_string(), + "secret_456".to_string(), + "http://localhost:8080/callback".to_string(), + ); + + let url = client.get_authorization_url("state_abc"); + assert!(url.contains("client_id=client_123")); + assert!(url.contains("state=state_abc")); + assert!(url.contains("user:email")); + } +} diff --git a/crates/auth/src/oauth/google.rs b/crates/auth/src/oauth/google.rs new file mode 100644 index 0000000..a4e764f --- /dev/null +++ b/crates/auth/src/oauth/google.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct GoogleUserProfile { + pub id: String, + pub email: String, + pub verified_email: bool, + pub name: Option, + pub picture: Option, +} + +pub struct GoogleOAuthClient { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, +} + +impl GoogleOAuthClient { + pub fn new(client_id: String, client_secret: String, redirect_uri: String) -> Self { + Self { + client_id, + client_secret, + redirect_uri, + } + } + + pub fn get_authorization_url(&self, state: &str) -> String { + format!( + "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=email%20profile&state={}", + urlencoding::encode(&self.client_id), + urlencoding::encode(&self.redirect_uri), + urlencoding::encode(state) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_google_auth_url() { + let client = GoogleOAuthClient::new( + "g_client".to_string(), + "g_secret".to_string(), + "http://localhost:8080/google/callback".to_string(), + ); + + let url = client.get_authorization_url("state_123"); + assert!(url.contains("client_id=g_client")); + assert!(url.contains("state=state_123")); + } +} diff --git a/crates/auth/src/oauth/mod.rs b/crates/auth/src/oauth/mod.rs new file mode 100644 index 0000000..cf3196e --- /dev/null +++ b/crates/auth/src/oauth/mod.rs @@ -0,0 +1,13 @@ +pub mod callback; +pub mod github; +pub mod google; +pub mod state; + +pub use callback::{OAuthCallbackQuery, OAuthSessionResult}; +pub use github::{GitHubOAuthClient, GitHubOAuthError, GitHubUserProfile}; +pub use google::{GoogleOAuthClient, GoogleUserProfile}; +pub use state::OAuthState; + +pub fn validate_oauth_provider(provider: &str) -> bool { + matches!(provider, "github" | "google") +} diff --git a/crates/auth/src/oauth/state.rs b/crates/auth/src/oauth/state.rs new file mode 100644 index 0000000..76a5e41 --- /dev/null +++ b/crates/auth/src/oauth/state.rs @@ -0,0 +1,37 @@ +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct OAuthState { + pub token: String, + pub created_at: i64, +} + +impl OAuthState { + pub fn generate() -> Self { + Self { + token: format!("st_{}", Uuid::new_v4().simple()), + created_at: chrono::Utc::now().timestamp(), + } + } + + pub fn verify(&self, input_state: &str, max_age_seconds: i64) -> bool { + if self.token != input_state { + return false; + } + + let now = chrono::Utc::now().timestamp(); + (now - self.created_at) <= max_age_seconds + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_oauth_state_verification() { + let state = OAuthState::generate(); + assert!(state.verify(&state.token, 300)); + assert!(!state.verify("invalid_state", 300)); + } +} diff --git a/crates/auth/src/passwords.rs b/crates/auth/src/passwords.rs new file mode 100644 index 0000000..457bd61 --- /dev/null +++ b/crates/auth/src/passwords.rs @@ -0,0 +1,24 @@ +use bcrypt::{hash, verify, BcryptError, DEFAULT_COST}; + +pub fn hash_password(password: &str) -> Result { + hash(password, DEFAULT_COST) +} + +pub fn verify_password(password: &str, hash_str: &str) -> Result { + verify(password, hash_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify_password() { + let raw = "SuperSecretPassword123!"; + let hashed = hash_password(raw).expect("Hashing failed"); + assert_ne!(raw, hashed); + + assert!(verify_password(raw, &hashed).unwrap()); + assert!(!verify_password("WrongPassword", &hashed).unwrap()); + } +} diff --git a/crates/auth/src/permissions.rs b/crates/auth/src/permissions.rs index 2163f5c..2dfa8d6 100644 --- a/crates/auth/src/permissions.rs +++ b/crates/auth/src/permissions.rs @@ -1,3 +1,124 @@ +use crate::rbac::Role; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Permission { + UsersRead, + UsersWrite, + ReposSync, + ResumesGenerate, + SystemManage, + PortfolioPublish, + ProfileUpdate, + CandidatesView, + ResumesView, + AnalyticsView, + SearchUse, +} + +impl Permission { + pub fn as_str(&self) -> &'static str { + match self { + Permission::UsersRead => "users:read", + Permission::UsersWrite => "users:write", + Permission::ReposSync => "repos:sync", + Permission::ResumesGenerate => "resumes:generate", + Permission::SystemManage => "system:manage", + Permission::PortfolioPublish => "portfolio:publish", + Permission::ProfileUpdate => "profile:update", + Permission::CandidatesView => "candidates:view", + Permission::ResumesView => "resumes:view", + Permission::AnalyticsView => "analytics:view", + Permission::SearchUse => "search:use", + } + } +} + +pub fn get_permissions_for_role(role: Role) -> Vec { + match role { + Role::Admin => vec![ + Permission::UsersRead, + Permission::UsersWrite, + Permission::ReposSync, + Permission::ResumesGenerate, + Permission::SystemManage, + Permission::PortfolioPublish, + Permission::ProfileUpdate, + Permission::CandidatesView, + Permission::ResumesView, + Permission::AnalyticsView, + Permission::SearchUse, + ], + Role::Developer => vec![ + Permission::ReposSync, + Permission::ResumesGenerate, + Permission::PortfolioPublish, + Permission::ProfileUpdate, + ], + Role::Recruiter => vec![ + Permission::CandidatesView, + Permission::ResumesView, + Permission::AnalyticsView, + Permission::SearchUse, + ], + } +} + pub fn check_permission(permission: &str, granted_permissions: &[&str]) -> bool { granted_permissions.contains(&permission) } + +pub fn role_has_permission(role: Role, permission: Permission) -> bool { + get_permissions_for_role(role).contains(&permission) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_admin_has_all_permissions() { + assert!(role_has_permission(Role::Admin, Permission::SystemManage)); + assert!(role_has_permission(Role::Admin, Permission::UsersRead)); + assert!(role_has_permission(Role::Admin, Permission::SearchUse)); + } + + #[test] + fn test_developer_permissions() { + assert!(role_has_permission(Role::Developer, Permission::ReposSync)); + assert!(role_has_permission( + Role::Developer, + Permission::ResumesGenerate + )); + assert!(role_has_permission( + Role::Developer, + Permission::PortfolioPublish + )); + assert!(role_has_permission( + Role::Developer, + Permission::ProfileUpdate + )); + assert!(!role_has_permission( + Role::Developer, + Permission::SystemManage + )); + } + + #[test] + fn test_recruiter_permissions() { + assert!(role_has_permission( + Role::Recruiter, + Permission::CandidatesView + )); + assert!(role_has_permission( + Role::Recruiter, + Permission::ResumesView + )); + assert!(role_has_permission( + Role::Recruiter, + Permission::AnalyticsView + )); + assert!(role_has_permission(Role::Recruiter, Permission::SearchUse)); + assert!(!role_has_permission(Role::Recruiter, Permission::ReposSync)); + } +} diff --git a/crates/auth/src/rbac.rs b/crates/auth/src/rbac.rs index 93ef9ef..f12e1d1 100644 --- a/crates/auth/src/rbac.rs +++ b/crates/auth/src/rbac.rs @@ -1,13 +1,82 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Role { Admin, Developer, Recruiter, } +impl std::str::FromStr for Role { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "admin" => Ok(Role::Admin), + "developer" | "dev" => Ok(Role::Developer), + "recruiter" => Ok(Role::Recruiter), + _ => Err(()), + } + } +} + +impl Role { + pub fn parse(role_str: &str) -> Option { + role_str.parse().ok() + } + + pub fn as_str(&self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Developer => "developer", + Role::Recruiter => "recruiter", + } + } +} + pub fn has_role(user_role: &str, required_role: Role) -> bool { + let parsed_role = match Role::parse(user_role) { + Some(r) => r, + None => return false, + }; + match required_role { - Role::Admin => user_role == "admin", - Role::Developer => user_role == "developer" || user_role == "admin", - Role::Recruiter => user_role == "recruiter" || user_role == "admin", + Role::Admin => parsed_role == Role::Admin, + Role::Developer => parsed_role == Role::Developer || parsed_role == Role::Admin, + Role::Recruiter => parsed_role == Role::Recruiter || parsed_role == Role::Admin, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_role_parsing() { + assert_eq!(Role::parse("admin"), Some(Role::Admin)); + assert_eq!(Role::parse("DEVELOPER"), Some(Role::Developer)); + assert_eq!(Role::parse("recruiter"), Some(Role::Recruiter)); + assert_eq!(Role::parse("invalid_role"), None); + } + + #[test] + fn test_admin_inherits_all_roles() { + assert!(has_role("admin", Role::Admin)); + assert!(has_role("admin", Role::Developer)); + assert!(has_role("admin", Role::Recruiter)); + } + + #[test] + fn test_developer_role_hierarchy() { + assert!(has_role("developer", Role::Developer)); + assert!(!has_role("developer", Role::Admin)); + assert!(!has_role("developer", Role::Recruiter)); + } + + #[test] + fn test_recruiter_role_hierarchy() { + assert!(has_role("recruiter", Role::Recruiter)); + assert!(!has_role("recruiter", Role::Admin)); + assert!(!has_role("recruiter", Role::Developer)); } } diff --git a/crates/auth/src/refresh_tokens.rs b/crates/auth/src/refresh_tokens.rs index de0f9f0..20f97d2 100644 --- a/crates/auth/src/refresh_tokens.rs +++ b/crates/auth/src/refresh_tokens.rs @@ -3,3 +3,22 @@ use uuid::Uuid; pub fn generate_refresh_token() -> String { format!("rt_{}", Uuid::new_v4().simple()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_refresh_token_format() { + let token = generate_refresh_token(); + assert!(token.starts_with("rt_")); + assert_eq!(token.len(), 35); // "rt_" (3) + 32 hex chars + } + + #[test] + fn test_generate_refresh_tokens_are_unique() { + let token1 = generate_refresh_token(); + let token2 = generate_refresh_token(); + assert_ne!(token1, token2); + } +} diff --git a/crates/auth/tests/integration_tests.rs b/crates/auth/tests/integration_tests.rs new file mode 100644 index 0000000..dcd26d5 --- /dev/null +++ b/crates/auth/tests/integration_tests.rs @@ -0,0 +1,56 @@ +use auth::{ + create_jwt, generate_refresh_token, hash_password, verify_jwt, verify_password, + GitHubOAuthClient, OAuthState, Permission, Role, +}; +use uuid::Uuid; + +#[test] +fn test_end_to_end_auth_flow() { + // 1. Password Hashing & Registration + let raw_password = "DeveloperSecurePassword123!"; + let password_hash = hash_password(raw_password).expect("Hashing failed"); + assert!(verify_password(raw_password, &password_hash).unwrap()); + + // 2. OAuth State Generation & CSRF Protection + let state = OAuthState::generate(); + assert!(state.verify(&state.token, 300)); + assert!(!state.verify("tampered_state", 300)); + + // 3. GitHub OAuth Client Authorization URL + let github_client = GitHubOAuthClient::new( + "test_client_id".to_string(), + "test_client_secret".to_string(), + "http://localhost:8080/api/v1/auth/github/callback".to_string(), + ); + let auth_url = github_client.get_authorization_url(&state.token); + assert!(auth_url.contains("client_id=test_client_id")); + assert!(auth_url.contains("state=st_")); + + // 4. User ID & Tokens Issuance on Callback + let user_id = Uuid::new_v4(); + let email = "chamath@devresume.ai"; + let secret = "jwt_secret_key_32_chars_long_spec"; + + let access_token = create_jwt(user_id, email, secret).expect("JWT creation failed"); + let refresh_token = generate_refresh_token(); + + assert!(!access_token.is_empty()); + assert!(refresh_token.starts_with("rt_")); + + // 5. JWT Verification (simulating GET /api/v1/auth/me) + let claims = verify_jwt(&access_token, secret).expect("JWT verification failed"); + assert_eq!(claims.sub, user_id.to_string()); + assert_eq!(claims.email, email); + + // 6. Role & Permissions Enforcement + let dev_role = Role::Developer; + assert!(auth::role_has_permission(dev_role, Permission::ReposSync)); + assert!(auth::role_has_permission( + dev_role, + Permission::ResumesGenerate + )); + assert!(!auth::role_has_permission( + dev_role, + Permission::SystemManage + )); +} diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index b1e303f..a6d2a8a 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -17,8 +17,10 @@ impl Config { dotenvy::dotenv().ok(); Self { - database_url: env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://devresume_user:devresume_password@localhost:5432/devresume_db".to_string()), + database_url: env::var("DATABASE_URL").unwrap_or_else(|_| { + "postgres://devresume_user:devresume_password@localhost:5432/devresume_db" + .to_string() + }), redis_url: env::var("REDIS_URL") .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()), jwt_secret: env::var("JWT_SECRET") diff --git a/crates/github/src/repositories.rs b/crates/github/src/repositories.rs index d742369..30a1d5f 100644 --- a/crates/github/src/repositories.rs +++ b/crates/github/src/repositories.rs @@ -23,7 +23,10 @@ pub struct GithubRepoClient { impl GithubRepoClient { pub fn new() -> Self { Self { - client: Client::builder().user_agent("DevResume-AI").build().unwrap(), + client: Client::builder() + .user_agent("DevResume-AI") + .build() + .unwrap(), } } diff --git a/crates/interview/src/lib.rs b/crates/interview/src/lib.rs index 4cbb993..a76a45c 100644 --- a/crates/interview/src/lib.rs +++ b/crates/interview/src/lib.rs @@ -9,8 +9,15 @@ pub struct InterviewQuestion { pub fn generate_mock_interview_questions(topic: &str) -> Vec { vec![InterviewQuestion { - question: format!("Explain how memory management and ownership work in {}", topic), + question: format!( + "Explain how memory management and ownership work in {}", + topic + ), category: "Systems Engineering".to_string(), - sample_answer_outline: vec!["Ownership rules".to_string(), "Borrow checker".to_string(), "Lifetimes".to_string()], + sample_answer_outline: vec![ + "Ownership rules".to_string(), + "Borrow checker".to_string(), + "Lifetimes".to_string(), + ], }] } diff --git a/crates/notification/src/lib.rs b/crates/notification/src/lib.rs index 414bad5..0078bb5 100644 --- a/crates/notification/src/lib.rs +++ b/crates/notification/src/lib.rs @@ -8,6 +8,10 @@ pub struct NotificationPayload { } pub fn send_notification(payload: NotificationPayload) -> bool { - tracing::info!("Sending notification to {}: {}", payload.recipient, payload.title); + tracing::info!( + "Sending notification to {}: {}", + payload.recipient, + payload.title + ); true } diff --git a/crates/resume/src/builder.rs b/crates/resume/src/builder.rs index ab691fc..7ad4b4b 100644 --- a/crates/resume/src/builder.rs +++ b/crates/resume/src/builder.rs @@ -18,13 +18,22 @@ pub struct ResumeProjectData { pub highlights: Vec, } -pub fn build_resume_json(user_name: &str, user_email: &str, projects: Vec) -> ResumeData { +pub fn build_resume_json( + user_name: &str, + user_email: &str, + projects: Vec, +) -> ResumeData { ResumeData { name: user_name.to_string(), email: user_email.to_string(), title: "Senior Full Stack Software Engineer".to_string(), summary: "Automated developer career profile generated by DevResume AI.".to_string(), - skills: vec!["Rust".to_string(), "PostgreSQL".to_string(), "Next.js".to_string(), "Docker".to_string()], + skills: vec![ + "Rust".to_string(), + "PostgreSQL".to_string(), + "Next.js".to_string(), + "Docker".to_string(), + ], projects, } } diff --git a/crates/resume/src/export.rs b/crates/resume/src/export.rs index f680ae1..007cd1b 100644 --- a/crates/resume/src/export.rs +++ b/crates/resume/src/export.rs @@ -14,7 +14,9 @@ pub fn export_resume(data: &ResumeData, format: ExportFormat) -> Vec { ExportFormat::Pdf => format!("PDF Export for {}", data.name).into_bytes(), ExportFormat::Docx => format!("DOCX Export for {}", data.name).into_bytes(), ExportFormat::Png => format!("PNG Image for {}", data.name).into_bytes(), - ExportFormat::Html => format!("

{}

", data.name).into_bytes(), + ExportFormat::Html => { + format!("

{}

", data.name).into_bytes() + } ExportFormat::Markdown => format!("# {}\n{}", data.name, data.summary).into_bytes(), ExportFormat::Zip => format!("ZIP Bundle for {}", data.name).into_bytes(), } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 9a60f53..757ec14 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -19,6 +19,9 @@ impl EnterpriseStorageClient { StorageTier::Artifacts => "artifacts", StorageTier::Exports => "exports", }; - format!("http://localhost:9000/{}/{}/{}", self.bucket, prefix, filename) + format!( + "http://localhost:9000/{}/{}/{}", + self.bucket, prefix, filename + ) } } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index aa23969..81045fe 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -18,5 +18,8 @@ impl Default for Pagination { } pub fn format_slug(input: &str) -> String { - input.to_lowercase().replace(' ', "-").replace(['/', '\\', ':'], "") + input + .to_lowercase() + .replace(' ', "-") + .replace(['/', '\\', ':'], "") }