feat(auth): implement user registration and login with JWT authentication
- Added `bcrypt`, `jsonwebtoken`, and `once_cell` dependencies to manage password hashing and JWT handling. - Created `Claims` struct for JWT claims and implemented token generation in the login route. - Implemented user registration and authentication logic in the `auth` module. - Updated error handling to include validation errors. - Created new routes for user registration and login, and integrated them into the main router. - Added tests for the authentication flow, including registration and login scenarios. - Updated user model to include a password hash field. - Refactored user creation logic to include password validation. - Adjusted feed and user routes to utilize JWT for authentication.
This commit is contained in:
@@ -4,3 +4,4 @@ PORT=8000
|
|||||||
DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
|
DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
|
||||||
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
||||||
PREFORK=0
|
PREFORK=0
|
||||||
|
AUTH_SECRET=your_secret_key_here
|
||||||
|
84
thoughts-backend/Cargo.lock
generated
84
thoughts-backend/Cargo.lock
generated
@@ -127,8 +127,11 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"app",
|
"app",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bcrypt",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"jsonwebtoken",
|
||||||
"models",
|
"models",
|
||||||
|
"once_cell",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -143,8 +146,10 @@ dependencies = [
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bcrypt",
|
||||||
"models",
|
"models",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
|
"validator",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -435,6 +440,19 @@ version = "1.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"blowfish",
|
||||||
|
"getrandom 0.3.3",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bigdecimal"
|
name = "bigdecimal"
|
||||||
version = "0.4.8"
|
version = "0.4.8"
|
||||||
@@ -492,6 +510,16 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blowfish"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "borsh"
|
name = "borsh"
|
||||||
version = "1.5.7"
|
version = "1.5.7"
|
||||||
@@ -591,6 +619,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.40"
|
version = "4.5.40"
|
||||||
@@ -1605,6 +1643,15 @@ dependencies = [
|
|||||||
"syn 2.0.104",
|
"syn 2.0.104",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -1643,6 +1690,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kv-log-macro"
|
name = "kv-log-macro"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1992,6 +2054,16 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3117,6 +3189,18 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
@@ -14,6 +14,9 @@ serde = { workspace = true }
|
|||||||
tower = { workspace = true }
|
tower = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
validator = { workspace = true, features = ["derive"] }
|
validator = { workspace = true, features = ["derive"] }
|
||||||
|
bcrypt = "0.17.1"
|
||||||
|
jsonwebtoken = "9.3.1"
|
||||||
|
once_cell = "1.21.3"
|
||||||
|
|
||||||
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
|
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
|
||||||
tower-cookies = "0.11.0"
|
tower-cookies = "0.11.0"
|
||||||
|
@@ -34,6 +34,7 @@ impl HTTPError for UserError {
|
|||||||
UserError::Forbidden => StatusCode::FORBIDDEN,
|
UserError::Forbidden => StatusCode::FORBIDDEN,
|
||||||
UserError::UsernameTaken => StatusCode::BAD_REQUEST,
|
UserError::UsernameTaken => StatusCode::BAD_REQUEST,
|
||||||
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
|
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
|
||||||
|
UserError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,23 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{request::Parts, StatusCode},
|
http::{request::Parts, HeaderMap, StatusCode},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
|
|
||||||
// A dummy struct to represent an authenticated user.
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
// In a real app, this would contain user details from a validated JWT.
|
pub struct Claims {
|
||||||
|
pub sub: i32,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
static JWT_SECRET: Lazy<String> =
|
||||||
|
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
||||||
|
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
}
|
}
|
||||||
@@ -18,18 +29,29 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
_state: &AppState,
|
_state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
// For now, we'll just return a hardcoded user.
|
|
||||||
// In a real implementation, you would:
|
|
||||||
// 1. Extract the `Authorization: Bearer <token>` header.
|
|
||||||
// 2. Validate the JWT.
|
|
||||||
// 3. Extract the user ID from the token claims.
|
|
||||||
// 4. Return an error if the token is invalid or missing.
|
|
||||||
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
||||||
let user_id_str = user_id_header.to_str().unwrap_or("1");
|
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
||||||
let user_id = user_id_str.parse::<i32>().unwrap_or(1);
|
let user_id = user_id_str.parse::<i32>().unwrap_or(0);
|
||||||
return Ok(AuthUser { id: user_id });
|
return Ok(AuthUser { id: user_id });
|
||||||
} else {
|
|
||||||
return Ok(AuthUser { id: 1 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let token = get_token_from_header(&parts.headers)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
||||||
|
|
||||||
|
let decoding_key = DecodingKey::from_secret(JWT_SECRET.as_ref());
|
||||||
|
|
||||||
|
let claims = decode::<Claims>(&token, &decoding_key, &Validation::default())
|
||||||
|
.map(|data| data.claims)
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
|
||||||
|
|
||||||
|
Ok(AuthUser { id: claims.sub })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header| header.to_str().ok())
|
||||||
|
.and_then(|header| header.strip_prefix("Bearer "))
|
||||||
|
.map(|token| token.to_owned())
|
||||||
|
}
|
||||||
|
@@ -3,5 +3,6 @@ mod json;
|
|||||||
mod valid;
|
mod valid;
|
||||||
|
|
||||||
pub use auth::AuthUser;
|
pub use auth::AuthUser;
|
||||||
|
pub use auth::Claims;
|
||||||
pub use json::Json;
|
pub use json::Json;
|
||||||
pub use valid::Valid;
|
pub use valid::Valid;
|
||||||
|
93
thoughts-backend/api/src/routers/auth.rs
Normal file
93
thoughts-backend/api/src/routers/auth.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use axum::{
|
||||||
|
debug_handler, extract::State, http::StatusCode, response::IntoResponse, routing::post, Router,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
extractor::{Claims, Json, Valid},
|
||||||
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
|
};
|
||||||
|
use app::{persistence::auth, state::AppState};
|
||||||
|
use models::{
|
||||||
|
params::auth::{LoginParams, RegisterParams},
|
||||||
|
schemas::user::UserSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
static JWT_SECRET: Lazy<String> =
|
||||||
|
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/register",
|
||||||
|
request_body = RegisterParams,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "User registered", body = UserSchema),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "Username already exists", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Valid(Json(params)): Valid<Json<RegisterParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = auth::register_user(&state.conn, params).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(UserSchema::from(user))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/login",
|
||||||
|
request_body = LoginParams,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "User logged in", body = TokenResponse),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "Invalid credentials", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[debug_handler]
|
||||||
|
async fn login(
|
||||||
|
state: State<AppState>,
|
||||||
|
Valid(Json(params)): Valid<Json<LoginParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = auth::authenticate_user(&state.conn, params).await?;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user.id,
|
||||||
|
exp: (now + 3600 * 24) as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(JWT_SECRET.as_ref()),
|
||||||
|
)
|
||||||
|
.map_err(|e| ApiError::from(app::error::UserError::Internal(e.to_string())))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(TokenResponse { token })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_auth_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/register", post(register))
|
||||||
|
.route("/login", post(login))
|
||||||
|
}
|
@@ -10,7 +10,7 @@ use crate::{error::ApiError, extractor::AuthUser};
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/",
|
path = "",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
|
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
|
||||||
),
|
),
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
|
use crate::routers::auth::create_auth_router;
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
use root::create_root_router;
|
use root::create_root_router;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
@@ -17,6 +19,7 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(create_root_router())
|
.merge(create_root_router())
|
||||||
|
.nest("/auth", create_auth_router())
|
||||||
.nest("/users", create_user_router())
|
.nest("/users", create_user_router())
|
||||||
.nest("/thoughts", create_thought_router())
|
.nest("/thoughts", create_thought_router())
|
||||||
.nest("/feed", create_feed_router())
|
.nest("/feed", create_feed_router())
|
||||||
|
@@ -21,7 +21,7 @@ use crate::{
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/thoughts",
|
path = "",
|
||||||
request_body = CreateThoughtParams,
|
request_body = CreateThoughtParams,
|
||||||
responses(
|
responses(
|
||||||
(status = 201, description = "Thought created", body = ThoughtSchema),
|
(status = 201, description = "Thought created", body = ThoughtSchema),
|
||||||
@@ -49,7 +49,7 @@ async fn thoughts_post(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/thoughts/{id}",
|
path = "/{id}",
|
||||||
params(
|
params(
|
||||||
("id" = i32, Path, description = "Thought ID")
|
("id" = i32, Path, description = "Thought ID")
|
||||||
),
|
),
|
||||||
|
@@ -5,50 +5,22 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use sea_orm::{DbErr, TryIntoModel};
|
|
||||||
|
|
||||||
use app::persistence::{
|
use app::persistence::{
|
||||||
follow,
|
follow,
|
||||||
thought::get_thoughts_by_user,
|
thought::get_thoughts_by_user,
|
||||||
user::{create_user, get_user, search_users},
|
user::{get_user, search_users},
|
||||||
};
|
};
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||||
|
use models::schemas::thought::ThoughtListSchema;
|
||||||
use models::schemas::user::{UserListSchema, UserSchema};
|
use models::schemas::user::{UserListSchema, UserSchema};
|
||||||
use models::{params::user::CreateUserParams, schemas::thought::ThoughtListSchema};
|
|
||||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::extractor::{Json, Valid};
|
use crate::extractor::Json;
|
||||||
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
|
use crate::models::ApiErrorResponse;
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "",
|
|
||||||
request_body = CreateUserParams,
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "User created", body = UserSchema),
|
|
||||||
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
|
||||||
(status = 409, description = "Username already exists", body = ApiErrorResponse),
|
|
||||||
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
|
||||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
async fn users_post(
|
|
||||||
state: State<AppState>,
|
|
||||||
Valid(Json(params)): Valid<Json<CreateUserParams>>,
|
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
|
||||||
let result = create_user(&state.conn, params).await;
|
|
||||||
match result {
|
|
||||||
Ok(user) => {
|
|
||||||
let user = user.try_into_model().unwrap();
|
|
||||||
Ok((StatusCode::CREATED, Json(UserSchema::from(user))))
|
|
||||||
}
|
|
||||||
Err(DbErr::UnpackInsertId) => Err(UserError::UsernameTaken.into()),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "",
|
path = "",
|
||||||
@@ -195,7 +167,7 @@ async fn user_follow_delete(
|
|||||||
|
|
||||||
pub fn create_user_router() -> Router<AppState> {
|
pub fn create_user_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(users_post).get(users_get))
|
.route("/", get(users_get))
|
||||||
.route("/{id}", get(users_id_get))
|
.route("/{id}", get(users_id_get))
|
||||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||||
.route(
|
.route(
|
||||||
|
@@ -9,6 +9,8 @@ name = "app"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bcrypt = "0.17.1"
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
validator = "0.20"
|
||||||
|
|
||||||
sea-orm = { workspace = true }
|
sea-orm = { workspace = true }
|
||||||
|
@@ -3,6 +3,7 @@ pub struct Config {
|
|||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u32,
|
pub port: u32,
|
||||||
pub prefork: bool,
|
pub prefork: bool,
|
||||||
|
pub auth_secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -15,6 +16,7 @@ impl Config {
|
|||||||
.parse()
|
.parse()
|
||||||
.expect("PORT is not a number"),
|
.expect("PORT is not a number"),
|
||||||
prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"),
|
prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"),
|
||||||
|
auth_secret: std::env::var("AUTH_SECRET").unwrap_or_else(|_| "secret".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -5,6 +5,7 @@ pub enum UserError {
|
|||||||
Forbidden,
|
Forbidden,
|
||||||
UsernameTaken,
|
UsernameTaken,
|
||||||
AlreadyFollowing,
|
AlreadyFollowing,
|
||||||
|
Validation(String), // Added Validation variant
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ impl std::fmt::Display for UserError {
|
|||||||
UserError::Forbidden => write!(f, "You do not have permission to perform this action"),
|
UserError::Forbidden => write!(f, "You do not have permission to perform this action"),
|
||||||
UserError::UsernameTaken => write!(f, "Username is already taken"),
|
UserError::UsernameTaken => write!(f, "Username is already taken"),
|
||||||
UserError::AlreadyFollowing => write!(f, "You are already following this user"),
|
UserError::AlreadyFollowing => write!(f, "You are already following this user"),
|
||||||
|
UserError::Validation(msg) => write!(f, "Validation error: {}", msg),
|
||||||
UserError::Internal(msg) => write!(f, "Internal server error: {}", msg),
|
UserError::Internal(msg) => write!(f, "Internal server error: {}", msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
thoughts-backend/app/src/persistence/auth.rs
Normal file
54
thoughts-backend/app/src/persistence/auth.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use bcrypt::{hash, verify, BcryptError, DEFAULT_COST};
|
||||||
|
use models::{
|
||||||
|
domains::user,
|
||||||
|
params::auth::{LoginParams, RegisterParams},
|
||||||
|
};
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, QueryFilter, Set};
|
||||||
|
use validator::Validate; // Import the Validate trait
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
|
||||||
|
fn hash_password(password: &str) -> Result<String, BcryptError> {
|
||||||
|
hash(password, DEFAULT_COST)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
||||||
|
// Validate the parameters
|
||||||
|
params
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| UserError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let hashed_password =
|
||||||
|
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_user = user::ActiveModel {
|
||||||
|
username: Set(params.username),
|
||||||
|
password_hash: Set(Some(hashed_password)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
new_user.insert(db).await.map_err(|e| {
|
||||||
|
if let Some(sea_orm::SqlErr::UniqueConstraintViolation { .. }) = e.sql_err() {
|
||||||
|
UserError::UsernameTaken
|
||||||
|
} else {
|
||||||
|
UserError::Internal(e.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate_user(db: &DbConn, params: LoginParams) -> Result<user::Model, UserError> {
|
||||||
|
let user = user::Entity::find()
|
||||||
|
.filter(user::Column::Username.eq(params.username))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let password_hash = user.password_hash.as_ref().ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
if verify(params.password, password_hash).map_err(|e| UserError::Internal(e.to_string()))? {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(UserError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
23
thoughts-backend/doc/src/auth.rs
Normal file
23
thoughts-backend/doc/src/auth.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use api::{
|
||||||
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
|
routers::auth::*,
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
params::auth::{LoginParams, RegisterParams},
|
||||||
|
schemas::user::UserSchema,
|
||||||
|
};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(register, login),
|
||||||
|
components(schemas(
|
||||||
|
RegisterParams,
|
||||||
|
LoginParams,
|
||||||
|
UserSchema,
|
||||||
|
TokenResponse,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ParamsErrorResponse,
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct AuthApi;
|
@@ -1,8 +1,12 @@
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use utoipa::OpenApi;
|
use utoipa::{
|
||||||
|
openapi::security::{ApiKey, ApiKeyValue, Http, SecurityScheme},
|
||||||
|
Modify, OpenApi,
|
||||||
|
};
|
||||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod root;
|
mod root;
|
||||||
mod thought;
|
mod thought;
|
||||||
@@ -12,19 +16,37 @@ mod user;
|
|||||||
#[openapi(
|
#[openapi(
|
||||||
nest(
|
nest(
|
||||||
(path = "/", api = root::RootApi),
|
(path = "/", api = root::RootApi),
|
||||||
|
(path = "/auth", api = auth::AuthApi),
|
||||||
(path = "/users", api = user::UserApi),
|
(path = "/users", api = user::UserApi),
|
||||||
(path = "/thoughts", api = thought::ThoughtApi),
|
(path = "/thoughts", api = thought::ThoughtApi),
|
||||||
(path = "/feed", api = feed::FeedApi),
|
(path = "/feed", api = feed::FeedApi),
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "root", description = "Root API"),
|
(name = "root", description = "Root API"),
|
||||||
|
(name = "auth", description = "Authentication API"),
|
||||||
(name = "user", description = "User & Social API"),
|
(name = "user", description = "User & Social API"),
|
||||||
(name = "thought", description = "Thoughts API"),
|
(name = "thought", description = "Thoughts API"),
|
||||||
(name = "feed", description = "Feed API"),
|
(name = "feed", description = "Feed API"),
|
||||||
),
|
),
|
||||||
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
struct _ApiDoc;
|
struct _ApiDoc;
|
||||||
|
|
||||||
|
struct SecurityAddon;
|
||||||
|
impl Modify for SecurityAddon {
|
||||||
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||||
|
let components = openapi.components.get_or_insert_with(Default::default);
|
||||||
|
components.add_security_scheme(
|
||||||
|
"bearer_auth",
|
||||||
|
SecurityScheme::Http(Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)),
|
||||||
|
);
|
||||||
|
components.add_security_scheme(
|
||||||
|
"api_key",
|
||||||
|
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait ApiDoc {
|
pub trait ApiDoc {
|
||||||
fn attach_doc(self) -> Self;
|
fn attach_doc(self) -> Self;
|
||||||
}
|
}
|
||||||
|
@@ -13,7 +13,6 @@ use models::schemas::{
|
|||||||
paths(
|
paths(
|
||||||
users_get,
|
users_get,
|
||||||
users_id_get,
|
users_id_get,
|
||||||
users_post,
|
|
||||||
user_thoughts_get,
|
user_thoughts_get,
|
||||||
user_follow_post,
|
user_follow_post,
|
||||||
user_follow_delete
|
user_follow_delete
|
||||||
|
@@ -24,6 +24,8 @@ impl MigrationTrait for Migration {
|
|||||||
.not_null()
|
.not_null()
|
||||||
.unique_key(),
|
.unique_key(),
|
||||||
)
|
)
|
||||||
|
.to_owned()
|
||||||
|
.col(ColumnDef::new(User::PasswordHash).string())
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -41,4 +43,5 @@ pub(super) enum User {
|
|||||||
Table,
|
Table,
|
||||||
Id,
|
Id,
|
||||||
Username,
|
Username,
|
||||||
|
PasswordHash,
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,7 @@ pub struct Model {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
#[sea_orm(unique)]
|
#[sea_orm(unique)]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
19
thoughts-backend/models/src/params/auth.rs
Normal file
19
thoughts-backend/models/src/params/auth.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct RegisterParams {
|
||||||
|
#[validate(length(min = 3))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 6))]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct LoginParams {
|
||||||
|
#[validate(length(min = 3))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 6))]
|
||||||
|
pub password: String,
|
||||||
|
}
|
@@ -1,2 +1,3 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
@@ -6,4 +6,6 @@ use validator::Validate;
|
|||||||
pub struct CreateUserParams {
|
pub struct CreateUserParams {
|
||||||
#[validate(length(min = 2))]
|
#[validate(length(min = 2))]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
#[validate(length(min = 6))]
|
||||||
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
60
thoughts-backend/tests/api/auth.rs
Normal file
60
thoughts-backend/tests/api/auth.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::api::main::setup;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_jwt_request, make_post_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_flow() {
|
||||||
|
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let register_body = json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response =
|
||||||
|
make_post_request(app.router.clone(), "/auth/register", register_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["username"], "testuser");
|
||||||
|
assert!(v["id"].is_number());
|
||||||
|
|
||||||
|
let response = make_post_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/auth/register",
|
||||||
|
json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password456"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let login_body = json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = make_post_request(app.router.clone(), "/auth/login", login_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let token = v["token"].as_str().expect("token not found").to_string();
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
|
||||||
|
let bad_login_body = json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "wrongpassword"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = make_post_request(app.router.clone(), "/auth/login", bad_login_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
@@ -1,69 +1,86 @@
|
|||||||
use super::main::{create_test_user, setup};
|
use super::main::{create_user_with_password, setup};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use utils::testing::{make_get_request, make_post_request};
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_feed_and_user_thoughts() {
|
async fn test_feed_and_user_thoughts() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
|
create_user_with_password(&app.db, "user1", "password1").await;
|
||||||
create_test_user(&app.db, "user2").await;
|
create_user_with_password(&app.db, "user2", "password2").await;
|
||||||
create_test_user(&app.db, "user3").await;
|
create_user_with_password(&app.db, "user3", "password3").await;
|
||||||
|
|
||||||
// As user1, post a thought
|
// As user1, post a thought
|
||||||
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
let body = json!({ "content": "A thought from user1" }).to_string();
|
let body = json!({ "content": "A thought from user1" }).to_string();
|
||||||
make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await;
|
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), &token).await;
|
||||||
|
|
||||||
// As a different "user", create thoughts for user2 and user3 (we cheat here since auth is hardcoded)
|
// As a different "user", create thoughts for user2 and user3
|
||||||
app::persistence::thought::create_thought(
|
let token2 = super::main::login_user(app.router.clone(), "user2", "password2").await;
|
||||||
&app.db,
|
let body2 = json!({ "content": "user2 was here" }).to_string();
|
||||||
2,
|
make_jwt_request(
|
||||||
models::params::thought::CreateThoughtParams {
|
app.router.clone(),
|
||||||
content: "user2 was here".to_string(),
|
"/thoughts",
|
||||||
},
|
"POST",
|
||||||
|
Some(body2),
|
||||||
|
&token2,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.unwrap();
|
|
||||||
app::persistence::thought::create_thought(
|
|
||||||
&app.db,
|
|
||||||
3,
|
|
||||||
models::params::thought::CreateThoughtParams {
|
|
||||||
content: "user3 checking in".to_string(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 1. Get thoughts for user2 - should only see their thought
|
let token3 = super::main::login_user(app.router.clone(), "user3", "password3").await;
|
||||||
let response = make_get_request(app.router.clone(), "/users/user2/thoughts", Some(2)).await;
|
let body3 = json!({ "content": "user3 checking in" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body3),
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 1. Get thoughts for user2 - should only see their thought plus their own
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/thoughts",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
||||||
|
|
||||||
// 2. user1's feed is initially empty
|
// 2. user1's feed has only their own thought (not following anyone)
|
||||||
let response = make_get_request(app.router.clone(), "/feed", Some(1)).await;
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert!(v["thoughts"].as_array().unwrap().is_empty());
|
|
||||||
|
|
||||||
// 3. user1 follows user2
|
|
||||||
make_post_request(
|
|
||||||
app.router.clone(),
|
|
||||||
"/users/user2/follow",
|
|
||||||
"".to_string(),
|
|
||||||
Some(1),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 4. user1's feed now has user2's thought
|
|
||||||
let response = make_get_request(app.router.clone(), "/feed", Some(1)).await;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["thoughts"][0]["author_username"], "user1");
|
||||||
|
assert_eq!(v["thoughts"][0]["content"], "A thought from user1");
|
||||||
|
|
||||||
|
// 3. user1 follows user2
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. user1's feed now has user2's thought
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2);
|
||||||
assert_eq!(v["thoughts"][0]["author_username"], "user2");
|
assert_eq!(v["thoughts"][0]["author_username"], "user2");
|
||||||
|
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
||||||
|
assert_eq!(v["thoughts"][1]["author_username"], "user1");
|
||||||
|
assert_eq!(v["thoughts"][1]["content"], "A thought from user1");
|
||||||
}
|
}
|
||||||
|
@@ -1,48 +1,69 @@
|
|||||||
use super::main::{create_test_user, setup};
|
use super::main::{create_user_with_password, setup};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use utils::testing::{make_delete_request, make_post_request};
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_follow_endpoints() {
|
async fn test_follow_endpoints() {
|
||||||
|
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
|
|
||||||
create_test_user(&app.db, "user2").await;
|
create_user_with_password(&app.db, "user1", "password1").await;
|
||||||
|
create_user_with_password(&app.db, "user2", "password2").await;
|
||||||
|
|
||||||
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
|
|
||||||
// 1. user1 follows user2
|
// 1. user1 follows user2
|
||||||
let response = make_post_request(
|
let response = make_jwt_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
"/users/user2/follow",
|
"/users/user2/follow",
|
||||||
"".to_string(),
|
"POST",
|
||||||
None,
|
None,
|
||||||
|
&token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
// 2. user1 tries to follow user2 again (should fail)
|
// 2. user1 tries to follow user2 again (should fail)
|
||||||
let response = make_post_request(
|
let response = make_jwt_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
"/users/user2/follow",
|
"/users/user2/follow",
|
||||||
"".to_string(),
|
"POST",
|
||||||
None,
|
None,
|
||||||
|
&token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
// 3. user1 tries to follow a non-existent user
|
// 3. user1 tries to follow a non-existent user
|
||||||
let response = make_post_request(
|
let response = make_jwt_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
"/users/nobody/follow",
|
"/users/nobody/follow",
|
||||||
"".to_string(),
|
"POST",
|
||||||
None,
|
None,
|
||||||
|
&token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
// 4. user1 unfollows user2
|
// 4. user1 unfollows user2
|
||||||
let response = make_delete_request(app.router.clone(), "/users/user2/follow", None).await;
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
// 5. user1 tries to unfollow user2 again (should fail)
|
// 5. user1 tries to unfollow user2 again (should fail)
|
||||||
let response = make_delete_request(app.router.clone(), "/users/user2/follow", None).await;
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
use api::setup_router;
|
use api::setup_router;
|
||||||
use app::persistence::user::create_user;
|
use app::persistence::user::create_user;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use models::params::user::CreateUserParams;
|
use http_body_util::BodyExt;
|
||||||
|
use models::params::{auth::RegisterParams, user::CreateUserParams};
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use utils::testing::setup_test_db;
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_post_request, setup_test_db};
|
||||||
|
|
||||||
pub struct TestApp {
|
pub struct TestApp {
|
||||||
pub router: Router,
|
pub router: Router,
|
||||||
@@ -22,8 +24,27 @@ pub async fn setup() -> TestApp {
|
|||||||
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
|
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
|
||||||
let params = CreateUserParams {
|
let params = CreateUserParams {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
};
|
};
|
||||||
create_user(db, params)
|
create_user(db, params)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create test user");
|
.expect("Failed to create test user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) {
|
||||||
|
let params = RegisterParams {
|
||||||
|
username: username.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
};
|
||||||
|
app::persistence::auth::register_user(db, params)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test user with password");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_user(router: Router, username: &str, password: &str) -> String {
|
||||||
|
let login_body = json!({ "username": username, "password": password }).to_string();
|
||||||
|
let response = make_post_request(router, "/auth/login", login_body, None).await;
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
v["token"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod follow;
|
mod follow;
|
||||||
mod main;
|
mod main;
|
||||||
|
@@ -9,13 +9,10 @@ use crate::api::main::setup;
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_post_users() {
|
async fn test_post_users() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
let response = make_post_request(
|
|
||||||
app.router,
|
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
||||||
"/users",
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
r#"{"username": "test"}"#.to_owned(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
@@ -25,36 +22,25 @@ async fn test_post_users() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
pub(super) async fn test_post_users_error() {
|
pub(super) async fn test_post_users_error() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
let response = make_post_request(
|
|
||||||
app.router,
|
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
||||||
"/users",
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
r#"{"username": "1"}"#.to_owned(),
|
|
||||||
None,
|
println!("{:?}", response);
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let result: Value = serde_json::from_slice(&body).unwrap();
|
let result: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(result["message"], "Validation error");
|
assert_eq!(result["message"], "Validation error");
|
||||||
assert_eq!(result["details"]["username"][0]["code"], "length");
|
assert_eq!(result["details"]["username"][0]["code"], "length");
|
||||||
assert_eq!(result["details"]["username"][0]["message"], Value::Null);
|
|
||||||
assert_eq!(
|
|
||||||
result["details"]["username"][0]["params"]["min"],
|
|
||||||
Value::Number(2.into())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
pub async fn test_get_users() {
|
pub async fn test_get_users() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
make_post_request(
|
|
||||||
app.router.clone(),
|
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
||||||
"/users",
|
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
||||||
r#"{"username": "test"}"#.to_owned(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = make_get_request(app.router, "/users", None).await;
|
let response = make_get_request(app.router, "/users", None).await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
@@ -7,12 +7,15 @@ use models::params::user::CreateUserParams;
|
|||||||
pub(super) async fn test_user(db: &DatabaseConnection) {
|
pub(super) async fn test_user(db: &DatabaseConnection) {
|
||||||
let params = CreateUserParams {
|
let params = CreateUserParams {
|
||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = create_user(db, params).await.expect("Create user failed!");
|
let user = create_user(db, params).await.expect("Create user failed!");
|
||||||
let expected = user::ActiveModel {
|
let expected = user::ActiveModel {
|
||||||
id: Unchanged(1),
|
id: Unchanged(1),
|
||||||
username: Unchanged("test".to_owned()),
|
username: Unchanged("test".to_owned()),
|
||||||
|
password_hash: Unchanged(None),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(user, expected);
|
assert_eq!(user, expected);
|
||||||
}
|
}
|
||||||
|
@@ -49,3 +49,22 @@ pub async fn make_delete_request(app: Router, url: &str, user_id: Option<i32>) -
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn make_jwt_request(
|
||||||
|
app: Router,
|
||||||
|
url: &str,
|
||||||
|
method: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
token: &str,
|
||||||
|
) -> Response {
|
||||||
|
let builder = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", format!("Bearer {}", token));
|
||||||
|
|
||||||
|
let request_body = body.unwrap_or_default();
|
||||||
|
app.oneshot(builder.body(Body::from(request_body)).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod db;
|
mod db;
|
||||||
|
|
||||||
pub use api::{make_delete_request, make_get_request, make_post_request};
|
pub use api::{make_delete_request, make_get_request, make_jwt_request, make_post_request};
|
||||||
pub use db::setup_test_db;
|
pub use db::setup_test_db;
|
||||||
|
Reference in New Issue
Block a user