diff --git a/src/client/tokens.rs b/src/client/tokens.rs index 3730f72..03d74de 100644 --- a/src/client/tokens.rs +++ b/src/client/tokens.rs @@ -1,8 +1,8 @@ use anyhow::Result; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::client::BrewlogClient; -use crate::domain::tokens::Token; pub struct TokensClient<'a> { client: &'a BrewlogClient, @@ -37,7 +37,7 @@ impl<'a> TokensClient<'a> { self.client.handle_response(response).await } - pub async fn list(&self) -> Result> { + pub async fn list(&self) -> Result> { let url = self.client.endpoint("api/v1/tokens")?; let response = self @@ -49,7 +49,7 @@ impl<'a> TokensClient<'a> { self.client.handle_response(response).await } - pub async fn revoke(&self, id: &str) -> Result { + pub async fn revoke(&self, id: &str) -> Result { let url = self .client .endpoint(&format!("api/v1/tokens/{}/revoke", id))?; @@ -77,3 +77,13 @@ pub struct TokenResponse { pub name: String, pub token: String, } + +#[derive(Debug, Deserialize, Serialize)] +pub struct TokenInfo { + pub id: String, + pub user_id: String, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} diff --git a/src/server/routes/tokens.rs b/src/server/routes/tokens.rs index daafcd7..9d31b9a 100644 --- a/src/server/routes/tokens.rs +++ b/src/server/routes/tokens.rs @@ -1,7 +1,7 @@ use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; -use chrono::Utc; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::domain::ids::generate_id; @@ -24,6 +24,29 @@ pub struct CreateTokenResponse { pub token: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenResponse { + pub id: String, + pub user_id: String, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +impl From for TokenResponse { + fn from(token: Token) -> Self { + Self { + id: token.id, + user_id: token.user_id, + name: token.name, + created_at: token.created_at, + last_used_at: token.last_used_at, + revoked_at: token.revoked_at, + } + } +} + pub async fn create_token( State(state): State, Json(payload): Json, @@ -72,21 +95,23 @@ pub async fn create_token( pub async fn list_tokens( State(state): State, auth_user: AuthenticatedUser, -) -> Result>, StatusCode> { +) -> Result>, StatusCode> { let tokens = state .token_repo .list_by_user(auth_user.0.id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(tokens)) + let token_responses: Vec = tokens.into_iter().map(TokenResponse::from).collect(); + + Ok(Json(token_responses)) } pub async fn revoke_token( State(state): State, auth_user: AuthenticatedUser, Path(token_id): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { // Get the token to ensure it exists and belongs to the user let token = state .token_repo @@ -106,5 +131,5 @@ pub async fn revoke_token( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(revoked_token)) + Ok(Json(TokenResponse::from(revoked_token))) } diff --git a/tests/server/auth_api.rs b/tests/server/auth_api.rs new file mode 100644 index 0000000..6b2ace1 --- /dev/null +++ b/tests/server/auth_api.rs @@ -0,0 +1,259 @@ +use reqwest::{Client, StatusCode}; +use serde_json::json; + +use crate::helpers::spawn_app_with_auth; + +#[tokio::test] +async fn test_create_token_with_valid_credentials() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // Create a token + let response = client + .post(&app.api_url("/tokens")) + .json(&json!({ + "username": "admin", + "password": "test_password", + "name": "test-token" + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); + + let body: serde_json::Value = response.json().await.expect("Failed to parse response"); + assert!(body.get("id").is_some()); + assert_eq!(body.get("name").unwrap(), "test-token"); + assert!(body.get("token").is_some()); + + // Token should be a non-empty string + let token = body.get("token").unwrap().as_str().unwrap(); + assert!(!token.is_empty()); +} + +#[tokio::test] +async fn test_create_token_with_invalid_credentials() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // Try to create a token with wrong password + let response = client + .post(&app.api_url("/tokens")) + .json(&json!({ + "username": "admin", + "password": "wrong_password", + "name": "test-token" + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_list_tokens_requires_authentication() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // Try to list tokens without authentication + let response = client + .get(&app.api_url("/tokens")) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_list_tokens_with_authentication() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // First, create a token + let create_response = client + .post(&app.api_url("/tokens")) + .json(&json!({ + "username": "admin", + "password": "test_password", + "name": "test-token" + })) + .send() + .await + .expect("Failed to send request"); + + let create_body: serde_json::Value = create_response + .json() + .await + .expect("Failed to parse response"); + let token = create_body.get("token").unwrap().as_str().unwrap(); + + // Now list tokens with authentication + let response = client + .get(&app.api_url("/tokens")) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); + + let tokens: Vec = response.json().await.expect("Failed to parse response"); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].get("name").unwrap(), "test-token"); +} + +#[tokio::test] +async fn test_revoke_token() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // First, create a token + let create_response = client + .post(&app.api_url("/tokens")) + .json(&json!({ + "username": "admin", + "password": "test_password", + "name": "test-token" + })) + .send() + .await + .expect("Failed to send request"); + + let create_body: serde_json::Value = create_response + .json() + .await + .expect("Failed to parse response"); + let token = create_body.get("token").unwrap().as_str().unwrap(); + let token_id = create_body.get("id").unwrap().as_str().unwrap(); + + // Revoke the token + let response = client + .post(&app.api_url(&format!("/tokens/{}/revoke", token_id))) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); + + let body: serde_json::Value = response.json().await.expect("Failed to parse response"); + assert!(body.get("revoked_at").is_some()); +} + +#[tokio::test] +async fn test_revoked_token_cannot_be_used() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // Create a token + let create_response = client + .post(&app.api_url("/tokens")) + .json(&json!({ + "username": "admin", + "password": "test_password", + "name": "test-token" + })) + .send() + .await + .expect("Failed to send request"); + + let create_body: serde_json::Value = create_response + .json() + .await + .expect("Failed to parse response"); + let token = create_body.get("token").unwrap().as_str().unwrap(); + let token_id = create_body.get("id").unwrap().as_str().unwrap(); + + // Revoke the token + client + .post(&app.api_url(&format!("/tokens/{}/revoke", token_id))) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .expect("Failed to send request"); + + // Try to use the revoked token + let response = client + .get(&app.api_url("/tokens")) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_protected_endpoints_require_authentication() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // Try to create a roaster without authentication + let response = client + .post(&app.api_url("/roasters")) + .json(&json!({ + "name": "Test Roaster", + "country": "UK" + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_protected_endpoints_work_with_authentication() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // Create a token + let create_response = client + .post(&app.api_url("/tokens")) + .json(&json!({ + "username": "admin", + "password": "test_password", + "name": "test-token" + })) + .send() + .await + .expect("Failed to send request"); + + let create_body: serde_json::Value = create_response + .json() + .await + .expect("Failed to parse response"); + let token = create_body.get("token").unwrap().as_str().unwrap(); + + // Create a roaster with authentication + let response = client + .post(&app.api_url("/roasters")) + .header("Authorization", format!("Bearer {}", token)) + .json(&json!({ + "name": "Test Roaster", + "country": "UK" + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[tokio::test] +async fn test_read_endpoints_dont_require_authentication() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // List roasters without authentication should work + let response = client + .get(&app.api_url("/roasters")) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 0b09675..87f74e0 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -1,13 +1,20 @@ use std::sync::Arc; -use brewlog::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository}; +use brewlog::domain::repositories::{ + RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository, +}; use brewlog::domain::roasters::{NewRoaster, Roaster}; +use brewlog::domain::users::User; +use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; +use brewlog::infrastructure::repositories::tokens::SqlTokenRepository; +use brewlog::infrastructure::repositories::users::SqlUserRepository; use brewlog::server::routes::app_router; use brewlog::server::server::AppState; +use chrono::Utc; use reqwest::Client; use tokio::net::TcpListener; @@ -17,6 +24,10 @@ pub struct TestApp { pub roast_repo: Arc, #[allow(dead_code)] pub timeline_repo: Arc, + #[allow(dead_code)] + pub user_repo: Option>, + #[allow(dead_code)] + pub token_repo: Option>, } impl TestApp { @@ -41,12 +52,18 @@ pub async fn spawn_app() -> TestApp { let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); + let user_repo: Arc = + Arc::new(SqlUserRepository::new(database.clone_pool())); + let token_repo: Arc = + Arc::new(SqlTokenRepository::new(database.clone_pool())); // Create application state let state = AppState::new( roaster_repo.clone(), roast_repo.clone(), timeline_repo.clone(), + user_repo.clone(), + token_repo.clone(), ); // Create router @@ -72,9 +89,33 @@ pub async fn spawn_app() -> TestApp { roaster_repo, roast_repo, timeline_repo, + user_repo: Some(user_repo), + token_repo: Some(token_repo), } } +pub async fn spawn_app_with_auth() -> TestApp { + let app = spawn_app().await; + + // Create admin user with known password + let password_hash = hash_password("test_password").expect("Failed to hash password"); + let admin_user = User::new( + "test_admin_id".to_string(), + "admin".to_string(), + password_hash, + Utc::now(), + ); + + app.user_repo + .as_ref() + .unwrap() + .insert(admin_user) + .await + .expect("Failed to create admin user"); + + app +} + pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> Roaster { let client = Client::new(); let response = client diff --git a/tests/server/main.rs b/tests/server/main.rs index f6816b9..bd2ba0b 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -1,3 +1,4 @@ +pub mod auth_api; pub mod helpers; pub mod roasters_api; pub mod roasts_api;