chore(history): squash 100 commit(s) from 2026-09-24

- fix(backend): case-insensitive unique email, revoke PUBLIC schema access, pin Argon2id params
- test(backend): assert password length cap boundary (256 ok, 257 rejected)
- docs(backend): plan — typed JWT token kinds so refresh/device tokens cannot pass as access tokens
- feat(backend): JWT access/refresh token issue and verify
- feat(backend): accounts repository (create/find_by_email/find_by_id)
- refactor(gui): LoVisualAddonManagerScreen 989→10 файлов addon/ (8.5.2)
- docs(backend): plan — fix sqlx::migrate! path in integration tests
- feat(backend): POST /auth/register and /auth/login
- refactor(settings): SettingsPanelComponent 715→81 + 6 helpers (8.5.2)
- docs(backend): plan — harden device flow (single-use codes, bounded store, 404/429)
- feat(backend): OAuth device authorization grant for mod login
- fix(backend): first confirm wins for device codes
- docs(backend): plan — split Task 8 (refactor) and Task 9 (avatars), harden avatar handling
- refactor(mixins): LocalPlayerMixin 691→111 + 4 handlers (8.5.2)
- refactor(visuals): Trails 687→130 (8.5.2)
- refactor(render): ItemBatchRenderer 677->100 (8.5.2)
- refactor(visuals): ReimaginedVisual 674→118 + 5 helpers (8.5.2)
- refactor(config): ConfigSerializer 668→91 + 4 helpers (8.5.2)
- refactor(hud): DynamicIsland 661→158 + 4 helpers (8.5.2)
- refactor(render): GlStencilFramebufferSupport 666→169 (8.5.2)
- refactor(gui): MenuScreen 669→128 + 4 helpers (8.5.2)
- refactor(render): UiStyle 644→170 + 3 helpers (8.5.2)
- refactor(gui): ModuleComponent 613→98 + 4 helpers (8.5.2)
- refactor(media): MediaSessionService 616→200 + 4 helpers (8.5.2)
- refactor(gui): RelationsComponent 661→59 + 4 helpers (8.5.2)
- chore(license): strip GPL file headers from all Java sources
- refactor(aiming): PointTracker 583→168 + 2 helpers (8.5.2)
- refactor(gui): LoVisualProxyManagerScreen 591→132 + 2 helpers (8.5.2)
- refactor(visuals): KillEffect 588→96 + 4 helpers (8.5.2)
- refactor(render): MeshBuilder +4 helpers (8.5.2)
- refactor(visuals): extract WorldParticlesRender helper (8.5.2)
- refactor(world): ExplosionDamageUtil 551→116 + 2 helpers (8.5.2)
- refactor(visuals): TazikHat 596->179 + Model + Palette in hats/tazik (8.5.2)
- chore(license): strip GPL header from remaining 30 files and make strip script variant-aware
- refactor(gui): ThemeComponent 561->166 + CardRenderer + ScrollState (8.5.2)
- refactor(hud): CustomHotbar 556→178 + Renderer + Selection + SelectionGradient (8.5.2)
- refactor(gui): ThemeCardRenderer perf + readability polish
- refactor(clickgui): CooldownRulesSetting 596->198 + Editor + DetailRenderer (8.5.2)
- refactor(hud): HudNotifier 561->200 + Painter + runtime/HudNotifierRuntime (8.5.2)
- refactor(theme): Themes 555->168 + impl/Transition + impl/Blending + impl/ProfileCodec (8.5.2)
- refactor(theme): EditableClickGuiTheme 205->185 + JavaDoc (8.5.2)
- refactor(theme): ThemeStore 491->128 + store/ThemeStoreJson + store/ThemeStoreIO (8.5.2)
- refactor(clickgui): ClickGuiRenderer 604->200 compacted one-line delegators + JavaDoc (8.5.2)
- refactor(mainmenu): LoVisualMainMenuScreen 551->161 + impl/Painter + impl/Renderer + impl/TextUtil (8.5.2)
- refactor(clickgui): ClickGuiTextEditorState 531->187 + impl/EditorCaret + impl/EditorPainter (8.5.2)
- refactor(tab): TabListModel 525->139 + model/Collector + model/Reader + model/Signature + model/TextSplitter (8.5.2)
- refactor(backend): shared bearer helper and test helpers, build_app takes Config, validate JWT secret strength
- refactor(module): ModuleManager 521->198 + impl/Registrar + impl/Dispatcher (8.5.2)
- feat(backend): avatar upload with decode, square crop, PNG re-encode and S3 storage
- refactor(clip): ClipFunction 512->146 + impl/Geometry + impl/Debug (8.5.2)
- docs(backend): implementation plans for gateway (auth hardening, gRPC, rate limits) and configs-service
- refactor(iris-patch): ShaderPatchEngine 499->146 + impl/Repo (8.5.2)
- chore(frontend): add router, react-query, fonts and vitest; dev proxy to gateway
- refactor(hud): ScriptedListHudPanel 499->158 + panel/Props + panel/Signature (8.5.2)
- refactor(hud): BaseHudElement 499->199 + impl/Registry + impl/Namer + impl/Prewarm (8.5.2)
- refactor(clickgui): Setting 498->170 + impl/Localization + impl/I18n (8.5.2)
- refact(viewmodel): split swing animations into camera/swing package
- refact(kineticlyrics): split module into stage, playback and modes
- rename(holeesp): module HoleESP -> CrystalHoles
- refact(crystalholes): split module into crystal scanner, renderer and safety
- refact(addonmanager): split manager into lifecycle, runtime, descriptors and profiles
- refact(accountconfig): split config into store, session and value helpers
- refactor(render): CustomTextRenderer 229->195, extract glyph-pass into GradientTexts helper
- docs(TODO): mark AddonManager split done; close 9.2 refactor gate
- refactor(media): LinuxMediaSession 441->148, split reader + track/seek state
- refactor(nametags): split NameTags into facade + impl helpers
- refactor(clickgui): split MainSettingsComponent into facade + scroll + model
- refactor(hud): split CustomBar into facade, model and BarSettings
- docs(frontend): implementation plan with design system from the mod theme
- feat(frontend): design tokens from the mod theme, fonts and shared UI kit
- fix(accounts): run migrations on startup, offload Argon2, validate register input, JSON error shape
- docs(gateway): plan note on splitting auth handlers before refresh endpoints
- feat(frontend): API client with silent refresh, error descriptions and test helpers
- style(mod): group compact one-line bulk query methods in ModuleManager
- feat(frontend): session restore, login and registration with client-side validation
- refactor(hud): split CustomHealthBar into facade + painter + script renderer
- docs(mod): record the 2026-09-24 HUD/settings split wave in TODO phase 8.5
- refactor(rhi): split GlStencilShapeClipBackend into facade + native-state + pass-lifecycle helpers
- refactor(rhi): split VulkanRenderStateBridge into facade + MSAA and stencil state helpers
- refactor(backtrack): split BacktrackController into facade + model + impl helpers
- refactor(svg): split SvgPathParser into facade + arc geometry + command/curve helpers
- refactor(mixin): split ClientPacketListenerMixin into hook-only mixin + handlers
- refactor(renderer3d): un-nest batch bindings + culling into sibling impl types
- refactor(renderwarp): extract static factories + geometry into impl helpers
- feat(backend): add common crate with shared JWT, internal gateway contract and accounts proto
- refactor(guimixin): move hook bodies into handlers, keep mixin as hooks + shadows
- feat(accounts): accept only gateway traffic, read identity from gateway header
- refactor(cacheduiscriptruntime): extract engine, hashing and frame stats into impl
- refactor(customskyboxrenderer): extract projection, shader passes and sun into impl
- refactor(betterchatstoremanager): extract persistence, key/path and hover helpers into impl
- feat(accounts): rotating opaque refresh tokens in httpOnly cookie, /auth/refresh and /auth/logout
- refactor(targetesp): extract crystal rendering subsystem into impl/TargetEspCrystalRenderer
- refactor(betterchathovercache): extract disk codec and lookup indexing into impl/ChatHoverCacheCodec
- refactor(microsoftauth): split HTTP transport, device-code and Xbox flows into impl/
- refactor(pvpcooldowns): extract local item-rule engine and defaults into impl/PvpCooldownRules
- refactor(lovisual): extract HUD/world render orchestration into HudRender helper
- refactor(statuseffectheuristics): extract palette/inference into ParticlePalette and color utils into ParticleColors
- refactor(dropesp): extract overlay/label render subsystem into impl/DropEspOverlayRenderer
- refactor(proxy): extract SOCKS handshake message builders into ProxyProtocolMessages
- refactor(eagleutil): promote EdgeRecovery controller and RecoveryMode to top-level class
This commit is contained in:
loki5512344 2026-09-24 23:52:12 +02:00
parent 9d08fa910a
commit 72bc4c7148
1897 changed files with 36199 additions and 39289 deletions

View file

@ -0,0 +1,162 @@
use serde_json::json;
use uuid::Uuid;
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 = "auth_flow/refresh.rs"]
mod refresh;
#[tokio::test]
async fn register_then_login_succeeds() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("flow-{}@example.com", Uuid::new_v4());
let register_response = server
.post("/auth/register")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Rider" }))
.await;
register_response.assert_status(axum::http::StatusCode::CREATED);
let login_response = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
login_response.assert_status_ok();
let body: serde_json::Value = login_response.json();
assert!(body["access_token"].is_string());
assert!(body["refresh_token"].is_null(), "refresh token must be in the httpOnly cookie, not the body");
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("wrongpw-{}@example.com", Uuid::new_v4());
server
.post("/auth/register")
.json(&json!({ "email": email, "password": "right-password", "nick": "Rider" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let login_response = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "wrong-password" }))
.await;
login_response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn duplicate_email_registration_returns_409() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("dup-{}@example.com", Uuid::new_v4());
server
.post("/auth/register")
.json(&json!({ "email": email, "password": "password123", "nick": "First" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let second = server
.post("/auth/register")
.json(&json!({ "email": email, "password": "password456", "nick": "Second" }))
.await;
second.assert_status(axum::http::StatusCode::CONFLICT);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn login_with_unknown_email_returns_401() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/login")
.json(&json!({ "email": format!("nobody-{}@example.com", Uuid::new_v4()), "password": "whatever-123" }))
.await;
response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_is_case_insensitive_on_email() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let tag = Uuid::new_v4();
let registered = format!("CaseUser-{tag}@Example.com");
server
.post("/auth/register")
.json(&json!({ "email": registered, "password": "password123", "nick": "Rider" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
server
.post("/auth/login")
.json(&json!({ "email": registered.to_lowercase(), "password": "password123" }))
.await
.assert_status_ok();
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(registered.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn malformed_json_body_returns_400_with_json_error_shape() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/register")
.content_type("application/json")
.bytes(axum::body::Bytes::from_static(b"{ this is not valid json"))
.await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json();
assert!(body["error"].is_string(), "expected {{\"error\": ...}}, got {body}");
}
#[tokio::test]
async fn register_rejects_oversized_password_with_400() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/register")
.json(&json!({
"email": format!("big-{}@example.com", Uuid::new_v4()),
"password": "a".repeat(10_000),
"nick": "Rider"
}))
.await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
}

View file

@ -0,0 +1,92 @@
use super::common;
use axum::http::StatusCode;
use axum_extra::extract::cookie::Cookie;
use serde_json::json;
async fn login(server: &axum_test::TestServer, email: &str) -> (String, String) {
let res = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
res.assert_status_ok();
let cookie = res.cookie("lv_refresh");
assert!(cookie.http_only().unwrap_or(false));
assert_eq!(cookie.path(), Some("/auth"));
let body: serde_json::Value = res.json();
assert!(body.get("refresh_token").is_none(), "refresh token must not be in the JSON body");
(body["access_token"].as_str().unwrap().to_owned(), cookie.value().to_owned())
}
#[tokio::test]
async fn refresh_rotates_the_cookie_and_issues_a_new_access_token() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await;
let res = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await;
res.assert_status_ok();
assert!(res.json::<serde_json::Value>()["access_token"].is_string());
assert_ne!(res.cookie("lv_refresh").value(), refresh);
}
#[tokio::test]
async fn reusing_a_rotated_refresh_token_revokes_all_sessions() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, first) = login(&server, &email).await;
let second = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first.clone()))
.await
.cookie("lv_refresh")
.value()
.to_owned();
// Attacker replays the old token -> rejected, and the legit new one dies too.
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first))
.await
.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", second))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_revokes_the_refresh_token() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await;
server
.post("/auth/logout")
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await
.assert_status(StatusCode::NO_CONTENT);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn refresh_without_cookie_or_with_garbage_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
server.post("/auth/refresh").await.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", "lvr_garbage"))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}

View file

@ -0,0 +1,100 @@
mod common;
use axum::http::StatusCode;
use axum_test::multipart::{MultipartForm, Part};
use common::ACCOUNT_ID_HEADER;
fn png_bytes() -> Vec<u8> {
let img = image::RgbaImage::new(64, 32);
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
fn form(bytes: Vec<u8>) -> MultipartForm {
MultipartForm::new().add_part("file", Part::bytes(bytes).file_name("a.png").mime_type("image/png"))
}
#[tokio::test]
async fn valid_upload_stores_avatar_and_returns_url() {
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;
let response = server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(png_bytes()))
.await;
response.assert_status_ok();
let url = response.json::<serde_json::Value>()["avatar_url"].as_str().unwrap().to_string();
assert!(url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"), "{url}");
assert!(url.ends_with(".png"), "{url}");
let stored: (String,) = sqlx::query_as(
"SELECT a.s3_key FROM avatars a JOIN accounts c ON c.id = a.account_id WHERE lower(c.email) = $1",
)
.bind(email.to_lowercase())
.fetch_one(&pool)
.await
.expect("avatars row must exist");
assert!(url.ends_with(&stored.0));
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn non_image_upload_is_rejected_with_400() {
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;
server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(b"definitely not an image".to_vec()))
.await
.assert_status(StatusCode::BAD_REQUEST);
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn oversized_upload_is_rejected_with_400() {
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;
server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(vec![0u8; 5 * 1024 * 1024 + 1]))
.await
.assert_status(StatusCode::BAD_REQUEST);
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn upload_without_identity_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
server
.post("/avatars")
.multipart(form(png_bytes()))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}

View file

@ -0,0 +1,52 @@
#![allow(dead_code, unused_imports)] // each test binary uses a different subset
use accounts_service::config::Config;
use axum_test::TestServer;
use uuid::Uuid;
// `::` — the workspace crate, not this module (which every test binary
// mounts as `mod common;`).
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
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());
let pool = sqlx::PgPool::connect(&url).await.expect("connect to test database");
sqlx::migrate!("./migrations").run(&pool).await.expect("run migrations");
pool
}
pub fn test_config() -> Config {
Config {
database_url: String::new(),
jwt_secret: "test-secret-test-secret-test-secret!".into(),
internal_key: TEST_INTERNAL_KEY.into(),
port: 0,
s3_endpoint: "http://localhost:9000".into(),
s3_bucket: "lovisual-avatars-test".into(),
s3_access_key: "minioadmin".into(),
s3_secret_key: "minioadmin".into(),
cookie_secure: false,
}
}
pub const TEST_INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
/// A server that behaves like it sits behind the gateway.
pub fn test_server(app: axum::Router) -> TestServer {
let mut server = TestServer::new(app);
server.add_header(INTERNAL_KEY_HEADER, TEST_INTERNAL_KEY);
server
}
/// Registers a fresh random account; returns its id and email.
pub async fn register_account(server: &TestServer) -> (Uuid, String) {
let email = format!("t-{}@example.com", Uuid::new_v4());
let res = server
.post("/auth/register")
.json(&serde_json::json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Tester" }))
.await;
res.assert_status(axum::http::StatusCode::CREATED);
let body: serde_json::Value = res.json();
(Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(), email)
}

View file

@ -1,14 +0,0 @@
#[tokio::test]
async fn migrations_create_accounts_table() {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = sqlx::PgPool::connect(&database_url).await.expect("connect");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
let row: (i64,) = sqlx::query_as("SELECT count(*) FROM accounts")
.fetch_one(&pool)
.await
.expect("accounts table must exist");
assert_eq!(row.0, 0);
}

View file

@ -0,0 +1,87 @@
mod common;
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;
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();
assert!(token_body["device_token"].is_string());
// 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;
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();
}

View file

@ -1,16 +0,0 @@
use axum_test::TestServer;
#[tokio::test]
async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db")
.expect("lazy pool");
let app = accounts_service::build_app(pool);
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok();
response.assert_text("ok");
}

View file

@ -0,0 +1,42 @@
mod common;
use axum_test::TestServer;
#[tokio::test]
async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db")
.expect("lazy pool");
let app = accounts_service::build_app(pool, &common::test_config());
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok();
response.assert_text("ok");
}
#[tokio::test]
async fn migrations_create_accounts_table() {
let pool = common::test_pool().await;
let row: (Option<String>,) = sqlx::query_as("SELECT to_regclass('accounts')::text")
.fetch_one(&pool)
.await
.expect("query must succeed");
assert!(row.0.is_some(), "accounts table must exist after migrations run");
}
#[tokio::test]
async fn api_routes_require_internal_key_but_health_does_not() {
let pool = common::test_pool().await;
// A raw server: no internal key header on any request, as if the
// service were reached directly, bypassing the gateway.
let server = axum_test::TestServer::new(accounts_service::build_app(pool, &common::test_config()));
server.get("/health").await.assert_status_ok();
server
.post("/device/code")
.await
.assert_status(axum::http::StatusCode::FORBIDDEN);
}