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:
parent
5c05f35a59
commit
97aaf9311d
5 changed files with 345 additions and 9 deletions
|
|
@ -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>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
259
tests/server/auth_api.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod auth_api;
|
||||
pub mod helpers;
|
||||
pub mod roasters_api;
|
||||
pub mod roasts_api;
|
||||
|
|
|
|||
Loading…
Reference in a new issue