feat: JWT auth, login endpoint, and test helpers

This commit is contained in:
2026-04-28 01:33:14 +02:00
parent 0da5dc5674
commit 83b25b1693
7 changed files with 389 additions and 3 deletions
+56 -1
View File
@@ -1 +1,56 @@
// auth — populated in Task 4
use axum::{extract::FromRequestParts, http::request::Parts};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TutorClaims { pub sub: i64, pub email: String, pub exp: usize }
fn secret() -> String {
std::env::var("JWT_SECRET").expect("JWT_SECRET not set")
}
pub fn encode_jwt(id: i64, email: &str) -> Result<String, AppError> {
let exp = chrono::Utc::now().timestamp() as usize + 86400 * 7;
let claims = TutorClaims { sub: id, email: email.into(), exp };
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret().as_bytes()))
.map_err(|_| AppError::Unauthorized)
}
pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
decode::<TutorClaims>(token, &DecodingKey::from_secret(secret().as_bytes()),
&Validation::default())
.map(|d| d.claims)
.map_err(|_| AppError::Unauthorized)
}
// Axum extractor: pulls JWT from Authorization: Bearer header
impl<S: Send + Sync> FromRequestParts<S> for TutorClaims {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let header = parts.headers.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
decode_jwt(header)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_jwt() {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
let token = encode_jwt(1, "test@example.com").unwrap();
let claims = decode_jwt(&token).unwrap();
assert_eq!(claims.sub, 1);
}
#[test]
fn invalid_jwt_rejected() {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
assert!(decode_jwt("not.a.token").is_err());
}
}
+3
View File
@@ -4,6 +4,9 @@ mod models;
mod auth;
mod routes;
#[cfg(test)]
mod test_helpers;
use axum::Router;
use tracing_subscriber::EnvFilter;
+62
View File
@@ -0,0 +1,62 @@
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use sqlx::SqlitePool;
use serde_json::{json, Value};
use crate::{auth, error::AppError};
#[derive(Deserialize)]
struct LoginRequest { email: String, password: String }
async fn login(
State(pool): State<SqlitePool>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, AppError> {
let tutor: Option<(i64, String, String)> = sqlx::query_as(
"SELECT id, email, password_hash FROM tutors WHERE email = ?"
).bind(&req.email).fetch_optional(&pool).await?;
let (id, email, hash) = tutor.ok_or(AppError::Unauthorized)?;
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
return Err(AppError::Unauthorized);
}
let token = auth::encode_jwt(id, &email)?;
Ok(Json(json!({"token": token})))
}
pub fn router() -> Router<SqlitePool> {
Router::new().route("/api/auth/login", post(login))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, post_json};
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
async fn login_returns_token(pool: SqlitePool) {
let hash = bcrypt::hash("secret", bcrypt::DEFAULT_COST).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test").bind("t@test.com").bind(&hash)
.execute(&pool).await.unwrap();
let app = crate::routes::build(pool);
let (status, body) = post_json(app, "/api/auth/login", "",
json!({"email":"t@test.com","password":"secret"})).await;
assert_eq!(status, 200);
assert!(serde_json::from_slice::<Value>(&body).unwrap()["token"].is_string());
}
#[sqlx::test(migrations = "./migrations")]
async fn login_wrong_password(pool: SqlitePool) {
let hash = bcrypt::hash("correct", bcrypt::DEFAULT_COST).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test").bind("t@test.com").bind(&hash)
.execute(&pool).await.unwrap();
let app = crate::routes::build(pool);
let (status, _) = post_json(app, "/api/auth/login", "",
json!({"email":"t@test.com","password":"wrong"})).await;
assert_eq!(status, 401);
}
}
+5 -1
View File
@@ -1,6 +1,10 @@
use axum::Router;
use sqlx::SqlitePool;
pub fn build(_pool: SqlitePool) -> Router {
mod auth_routes;
pub fn build(pool: SqlitePool) -> Router {
Router::new()
.merge(auth_routes::router())
.with_state(pool)
}
+55
View File
@@ -0,0 +1,55 @@
// cfg(test) only — this whole module is test-only
use sqlx::SqlitePool;
use axum::Router;
use tower::ServiceExt;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
/// Insert a test tutor (if not exists), return a valid JWT for that tutor.
pub async fn make_token(pool: &SqlitePool, email: &str) -> String {
let hash = bcrypt::hash("testpass", bcrypt::DEFAULT_COST).unwrap();
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test Tutor").bind(email).bind(&hash)
.execute(pool).await.unwrap();
let id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = ?")
.bind(email).fetch_one(pool).await.unwrap();
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
crate::auth::encode_jwt(id.0, email).unwrap()
}
/// Build the full Axum app wired with the given pool, plus a Bearer auth header value.
pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
let token = make_token(&pool, "tutor@test.com").await;
let app = crate::routes::build(pool);
(app, format!("Bearer {token}"))
}
/// POST JSON body to the app (one-shot), returns (StatusCode, response body bytes).
pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Value)
-> (StatusCode, bytes::Bytes)
{
let req = Request::builder()
.method("POST").uri(path)
.header("Content-Type", "application/json")
.header("Authorization", auth)
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
(status, body)
}
/// GET from the app (one-shot), returns (StatusCode, response body bytes).
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
let req = Request::builder()
.method("GET").uri(path)
.header("Authorization", auth)
.body(axum::body::Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
(status, body)
}