fix: CI workflow, password reset flow, health/limits in services, session refactor, dead mixin stub cleanup
This commit is contained in:
parent
45dd592c40
commit
f4e15b45c9
95 changed files with 899 additions and 185 deletions
|
|
@ -20,6 +20,8 @@ fn form(bytes: Vec<u8>) -> MultipartForm {
|
|||
|
||||
#[tokio::test]
|
||||
async fn valid_upload_stores_avatar_and_returns_url() {
|
||||
common::init_test_logging();
|
||||
common::ensure_avatar_bucket().await;
|
||||
let pool = common::test_pool().await;
|
||||
let server = common::test_server(accounts_service::build_app(
|
||||
pool.clone(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ use uuid::Uuid;
|
|||
// mounts as `mod common;`).
|
||||
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
|
||||
|
||||
pub fn init_test_logging() {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
}
|
||||
|
||||
pub async fn test_pool() -> sqlx::PgPool {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
|
||||
|
|
@ -60,3 +64,65 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) {
|
|||
email,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates the test avatar bucket if it does not exist yet, so the avatar
|
||||
/// tests are self-contained: any S3-compatible backend (MinIO, SeaweedFS)
|
||||
/// works without external `mc mb` bootstrap. Mirrors `S3Storage::from_config`.
|
||||
/// Retries briefly so a just-started container does not race the test.
|
||||
pub async fn ensure_avatar_bucket() {
|
||||
let creds =
|
||||
aws_sdk_s3::config::Credentials::new("minioadmin", "minioadmin", None, None, "static");
|
||||
let config = aws_sdk_s3::config::Builder::new()
|
||||
.endpoint_url("http://localhost:9000")
|
||||
.credentials_provider(creds)
|
||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||
.force_path_style(true)
|
||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||
.build();
|
||||
let client = aws_sdk_s3::Client::from_conf(config);
|
||||
let mut last_err = String::new();
|
||||
for _ in 0..30 {
|
||||
// Probe writability, not just bucket existence: a freshly started
|
||||
// S3 backend may still be electing volumes ("Not enough data nodes"
|
||||
// on SeaweedFS) right after create_bucket succeeds.
|
||||
match async {
|
||||
// Already-exists is success (MinIO: BucketAlreadyOwnedByYou,
|
||||
// SeaweedFS: BucketAlreadyExists); anything else aborts.
|
||||
if let Err(e) = client
|
||||
.create_bucket()
|
||||
.bucket("lovisual-avatars-test")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let msg = format!("{e:?}");
|
||||
if !msg.contains("BucketAlready") {
|
||||
return Err(msg);
|
||||
}
|
||||
}
|
||||
client
|
||||
.put_object()
|
||||
.bucket("lovisual-avatars-test")
|
||||
.key(".probe")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"probe"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let _ = client
|
||||
.delete_object()
|
||||
.bucket("lovisual-avatars-test")
|
||||
.key(".probe")
|
||||
.send()
|
||||
.await;
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(_) => return,
|
||||
Err(msg) => {
|
||||
last_err = msg;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("S3 backend is not writable: {last_err}");
|
||||
}
|
||||
|
|
|
|||
215
backend/accounts-service/tests/password_reset.rs
Normal file
215
backend/accounts-service/tests/password_reset.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
mod common;
|
||||
|
||||
use accounts_service::auth::reset::{Mail, MailBox};
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
|
||||
|
||||
type App = (TestServer, PgPool, UnboundedReceiver<Mail>);
|
||||
|
||||
async fn app() -> App {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let pool = common::test_pool().await;
|
||||
let server = common::test_server(accounts_service::build_app_with_mail(
|
||||
pool.clone(),
|
||||
&common::test_config(),
|
||||
MailBox::Queue(tx),
|
||||
));
|
||||
(server, pool, rx)
|
||||
}
|
||||
|
||||
async fn request_reset(server: &TestServer, email: &str) {
|
||||
server
|
||||
.post("/auth/forgot-password")
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.await
|
||||
.assert_status(StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
async fn reset_with(server: &TestServer, token: &str, password: &str) -> axum_test::TestResponse {
|
||||
server
|
||||
.post("/auth/reset-password")
|
||||
.json(&serde_json::json!({ "token": token, "new_password": password }))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgot_password_never_reveals_account_existence() {
|
||||
let (server, _pool, _mail) = app().await;
|
||||
|
||||
// Unknown email and known email must be indistinguishable: same status,
|
||||
// same body. Enumeration is the first step of account takeover.
|
||||
let unknown = server
|
||||
.post("/auth/forgot-password")
|
||||
.json(&serde_json::json!({ "email": "nobody-here@example.com" }))
|
||||
.await;
|
||||
unknown.assert_status(StatusCode::ACCEPTED);
|
||||
let unknown_body: serde_json::Value = unknown.json();
|
||||
assert_eq!(
|
||||
unknown_body["status"],
|
||||
"reset email sent if the account exists"
|
||||
);
|
||||
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
let known = server
|
||||
.post("/auth/forgot-password")
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.await;
|
||||
known.assert_status(StatusCode::ACCEPTED);
|
||||
let known_body: serde_json::Value = known.json();
|
||||
assert_eq!(unknown_body, known_body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_reset_flow_changes_password_and_kills_sessions() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
let old_password = "correct-horse-battery-staple";
|
||||
|
||||
// Log in to create a live refresh session the reset must kill.
|
||||
let login = server
|
||||
.post("/auth/login")
|
||||
.json(&serde_json::json!({ "email": email, "password": old_password }))
|
||||
.await;
|
||||
login.assert_status_ok();
|
||||
let old_refresh = refresh_cookie(&login).expect("login must set the refresh cookie");
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let token = mail.recv().await.expect("queue mailer must deliver").token;
|
||||
|
||||
reset_with(&server, &token, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Old password is dead, new password works.
|
||||
server
|
||||
.post("/auth/login")
|
||||
.json(&serde_json::json!({ "email": email, "password": old_password }))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
server
|
||||
.post("/auth/login")
|
||||
.json(&serde_json::json!({ "email": email, "password": "brand-new-password-1" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Every refresh session issued before the reset is revoked: replaying
|
||||
// the pre-reset cookie must not survive (a stolen session cannot
|
||||
// outlive a password change).
|
||||
let replay = server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(old_refresh.as_str().into())
|
||||
.await;
|
||||
replay.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_token_is_single_use() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let token = mail.recv().await.expect("mail").token;
|
||||
|
||||
reset_with(&server, &token, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
reset_with(&server, &token, "another-password-2")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_request_supersedes_pending_token() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let first = mail.recv().await.expect("mail").token;
|
||||
request_reset(&server, &email).await;
|
||||
let second = mail.recv().await.expect("mail").token;
|
||||
assert_ne!(first, second);
|
||||
|
||||
// The superseded token no longer works, the fresh one does.
|
||||
reset_with(&server, &first, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
reset_with(&server, &second, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_tokens_are_rejected_without_oracle() {
|
||||
let (server, _pool, _mail) = app().await;
|
||||
|
||||
server
|
||||
.post("/auth/reset-password")
|
||||
.json(&serde_json::json!({ "token": "short", "new_password": "brand-new-password-1" }))
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
// Unknown but well-formed token: same 400 family, and unlike the
|
||||
// shape-rejection above it must not leak which of unknown/expired/used
|
||||
// it is.
|
||||
let unknown = server
|
||||
.post("/auth/reset-password")
|
||||
.json(&serde_json::json!({
|
||||
"token": "lvpr_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"new_password": "brand-new-password-1"
|
||||
}))
|
||||
.await;
|
||||
unknown.assert_status(StatusCode::BAD_REQUEST);
|
||||
let body: serde_json::Value = unknown.json();
|
||||
assert_eq!(body["error"], "reset token is invalid or expired");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weak_password_is_rejected_before_token_consumption() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let token = mail.recv().await.expect("mail").token;
|
||||
|
||||
reset_with(&server, &token, "short12")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
// The token must still be usable afterwards: rejecting a weak password
|
||||
// must not burn the user's one link.
|
||||
reset_with(&server, &token, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_mail_only_goes_to_known_accounts() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
|
||||
request_reset(&server, "ghost@example.com").await;
|
||||
assert!(
|
||||
mail.try_recv().is_err(),
|
||||
"unknown account must not enqueue a reset email"
|
||||
);
|
||||
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
request_reset(&server, &email).await;
|
||||
let sent = mail.recv().await.expect("mail");
|
||||
assert_eq!(sent.to, email);
|
||||
assert!(sent.token.starts_with("lvpr_"));
|
||||
}
|
||||
|
||||
/// The refresh cookie is httpOnly and scoped to /auth; axum-test exposes
|
||||
/// response headers, so parse Set-Cookie directly.
|
||||
fn refresh_cookie(res: &axum_test::TestResponse) -> Option<String> {
|
||||
res.headers()
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.find_map(|v| {
|
||||
let s = v.to_str().ok()?;
|
||||
s.strip_prefix("lv_refresh=")
|
||||
.map(|rest| format!("lv_refresh={}", rest.split(';').next().unwrap_or("")))
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue