auth tests
This commit is contained in:
@@ -80,9 +80,15 @@ pub async fn login(
|
||||
) -> AppResult<Response> {
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let user: User = dsl::users
|
||||
let user: Option<User> = dsl::users
|
||||
.filter(dsl::username.eq(&payload.username))
|
||||
.first(&mut conn)?;
|
||||
.first(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
let user = match user {
|
||||
Some(user) => user,
|
||||
None => return Err(AppError::unauthorized()),
|
||||
};
|
||||
|
||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
@@ -15,12 +15,11 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::documents::{
|
||||
asset::load_primary_assets,
|
||||
correspondents::load_correspondents_for_documents,
|
||||
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||
tags::load_tags_for_documents,
|
||||
};
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
time::to_iso,
|
||||
|
||||
+267
-4
@@ -1,15 +1,49 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::body::Body;
|
||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||
use backend::models::NewUserMembership;
|
||||
use backend::schema::{tenants, user_memberships};
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUser {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginTenant {
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
tenant: LoginTenant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let password = "s3cret";
|
||||
app.insert_user("alice", password, "admin").await?;
|
||||
|
||||
let token = app.login_token("alice", password).await?;
|
||||
let (login, _) = login_with_session(&app, "alice", password).await?;
|
||||
|
||||
let response = app.get("/api/auth/me", Some(&token)).await?;
|
||||
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||
@@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_unknown_user() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = json!({ "username": "ghost", "password": "nope" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_invalid_password() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "valid";
|
||||
app.insert_user("robin", password, "admin").await?;
|
||||
|
||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "rotate";
|
||||
app.insert_user("rita", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let new_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(refreshed.tenant.slug, login.tenant.slug);
|
||||
|
||||
let me_response = app
|
||||
.get("/api/auth/me", Some(&refreshed.access_token))
|
||||
.await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let retry = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// new cookie should differ from old to avoid reuse
|
||||
assert_ne!(new_cookie, refresh_cookie);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "logout";
|
||||
app.insert_user("logan", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie(
|
||||
"/api/auth/logout",
|
||||
&json!({}),
|
||||
Some(&login.access_token),
|
||||
Some(&refresh_cookie),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let cleared_cookie = extract_refresh_cookie(response.headers())?;
|
||||
assert!(cleared_cookie.ends_with("="));
|
||||
|
||||
let after_logout = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn me_requires_authentication() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let unauthenticated = app.get("/api/auth/me", None).await?;
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
|
||||
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "multipass";
|
||||
let user_id = app.insert_user("multipass", password, "admin").await?;
|
||||
|
||||
let secondary_slug = "secondary".to_string();
|
||||
let slug_for_insert = secondary_slug.clone();
|
||||
let secondary_id = Uuid::new_v4();
|
||||
app.with_conn(move |conn| {
|
||||
diesel::insert_into(tenants::table)
|
||||
.values((
|
||||
tenants::id.eq(secondary_id),
|
||||
tenants::slug.eq(&slug_for_insert),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
tenant_id: secondary_id,
|
||||
role: "admin".to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let payload = json!({ "username": "multipass", "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||
assert!(selection.tenants.len() >= 2);
|
||||
let secondary = selection
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|tenant| tenant.slug == secondary_slug)
|
||||
.map(|t| t.tenant_id)
|
||||
.context("secondary tenant missing from selection")?;
|
||||
|
||||
let select_response = app
|
||||
.post_json(
|
||||
"/api/auth/select-tenant",
|
||||
&json!({ "tenant_id": secondary }),
|
||||
Some(&selection.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(select_response.status(), StatusCode::OK);
|
||||
let session_cookie = extract_refresh_cookie(select_response.headers())?;
|
||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||
assert_eq!(login.tenant.slug, secondary_slug);
|
||||
|
||||
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let refresh_response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
|
||||
.await?;
|
||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_session(
|
||||
app: &TestApp,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(LoginResponse, String)> {
|
||||
let payload = json!({ "username": username, "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
ensure_status(&response, StatusCode::OK)?;
|
||||
let refresh_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&body)
|
||||
.map_err(|_| anyhow!("expected login response with session"))?;
|
||||
Ok((login, refresh_cookie))
|
||||
}
|
||||
|
||||
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||
let header_value = headers
|
||||
.get(SET_COOKIE)
|
||||
.context("missing set-cookie header")?
|
||||
.to_str()
|
||||
.context("invalid set-cookie header")?;
|
||||
let cookie = header_value
|
||||
.split(';')
|
||||
.next()
|
||||
.context("set-cookie missing cookie value")?
|
||||
.to_string();
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
|
||||
if response.status() == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"unexpected status: got {}, expected {}",
|
||||
response.status(),
|
||||
expected
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
@@ -396,6 +396,16 @@ impl TestApp {
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
self.post_json_with_cookie(path, payload, token, None).await
|
||||
}
|
||||
|
||||
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
|
||||
&self,
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let body = serde_json::to_vec(payload)?;
|
||||
let mut builder = Request::builder()
|
||||
@@ -405,6 +415,9 @@ impl TestApp {
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header(header::COOKIE, cookie);
|
||||
}
|
||||
let request = builder.body(Body::from(body))?;
|
||||
Ok(self
|
||||
.router
|
||||
|
||||
Reference in New Issue
Block a user