LoVisual/backend/accounts-service/tests/device_flow.rs
loki5512344 c57f851a8b
feat(accounts): outgoing mail over SMTP (Resend), email verification, fail-closed password reset
- mail/ module (lettre, ru/en templates), links built only from PUBLIC_BASE_URL
- migration 0006: accounts.email_verified_at, shared email_tokens table (verify + reset), existing accounts marked verified
- reset mailer no longer logs tokens; RESET_MAIL_MODE=log is dev-only and refused with COOKIE_SECURE=true; Disabled by default answers 503
- forgot-password and resend-verification do their work in a background task (no timing oracle)
- device linking requires a verified email; email_verified exposed via /me and gRPC
- gateway rate limits, SMTP_* in compose and .env.example
- frontend: verify, forgot-password, reset-password pages, verify banner, ru/en strings
2026-10-09 21:08:33 +02:00

106 lines
3.3 KiB
Rust

mod common;
// A test root's submodules resolve against `tests/`, not the file's own
// directory — pin the path so `tests/` itself stays at 4 files.
#[path = "device_flow/links.rs"]
mod links;
use axum::http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn full_device_link_flow() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await;
common::mark_verified(&pool, account_id).await;
let code_response: serde_json::Value = server.post("/device/code").await.json();
let device_code = code_response["device_code"].as_str().unwrap();
let user_code = code_response["user_code"].as_str().unwrap();
let poll_before = server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await;
poll_before.assert_status(StatusCode::ACCEPTED);
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": user_code }))
.await
.assert_status_ok();
let poll_after = server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await;
poll_after.assert_status_ok();
let token_body: serde_json::Value = poll_after.json();
let device_token = token_body["device_token"].as_str().unwrap();
assert!(
device_token.starts_with("lvd_"),
"opaque device token, got {device_token}"
);
// Single use: the same device_code cannot be redeemed twice.
server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await
.assert_status(StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn confirm_without_identity_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let code: serde_json::Value = server.post("/device/code").await.json();
server
.post("/device/confirm")
.json(&json!({ "user_code": code["user_code"] }))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unknown_device_code_and_user_code_return_404() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await;
common::mark_verified(&pool, account_id).await;
server
.post("/device/token")
.json(&json!({ "device_code": "no-such-code" }))
.await
.assert_status(StatusCode::NOT_FOUND);
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": "ZZZZ-ZZZZ" }))
.await
.assert_status(StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}