fix(api): use TokenResponse DTO to exclude sensitive token_hash field

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 10:38:01 +00:00 committed by Jon Seager
parent 5c05f35a59
commit 97aaf9311d
No known key found for this signature in database
5 changed files with 345 additions and 9 deletions

View file

@ -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<Vec<Token>> {
pub async fn list(&self) -> Result<Vec<TokenInfo>> {
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<Token> {
pub async fn revoke(&self, id: &str) -> Result<TokenInfo> {
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<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}

View file

@ -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<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
impl From<Token> 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<AppState>,
Json(payload): Json<CreateTokenRequest>,
@ -72,21 +95,23 @@ pub async fn create_token(
pub async fn list_tokens(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
) -> Result<Json<Vec<Token>>, StatusCode> {
) -> Result<Json<Vec<TokenResponse>>, 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<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect();
Ok(Json(token_responses))
}
pub async fn revoke_token(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
Path(token_id): Path<String>,
) -> Result<Json<Token>, StatusCode> {
) -> Result<Json<TokenResponse>, 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)))
}

259
tests/server/auth_api.rs Normal file
View file

@ -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<serde_json::Value> = 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);
}

View file

@ -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<dyn RoastRepository>,
#[allow(dead_code)]
pub timeline_repo: Arc<dyn TimelineEventRepository>,
#[allow(dead_code)]
pub user_repo: Option<Arc<dyn UserRepository>>,
#[allow(dead_code)]
pub token_repo: Option<Arc<dyn TokenRepository>>,
}
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<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> =
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

View file

@ -1,3 +1,4 @@
pub mod auth_api;
pub mod helpers;
pub mod roasters_api;
pub mod roasts_api;