feat(opaque): add end to end integration test
This commit is contained in:
@@ -3387,3 +3387,197 @@ fn base64_url_encode(input: &[u8]) -> String {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 4 gate: end-to-end service-level integration test ─────────────
|
||||||
|
//
|
||||||
|
// The repo layer proves `is_migrated` returns the right bool
|
||||||
|
// (`opaque_pg_repository.rs::is_migrated_tracks_mark_and_clear_state_transitions`).
|
||||||
|
// This module proves the WIRING between `is_migrated` and `login()` —
|
||||||
|
// that a mark_migrated actually flips a subsequent legacy login from
|
||||||
|
// success to `OpaqueLoginRequired`-shaped `AccessDenied`. Without
|
||||||
|
// this test, the field / builder / gate could rot independently
|
||||||
|
// (silent field rename, missing `.with_opaque_repo` call in the
|
||||||
|
// factory, etc.) and only surface when an operator actually rolls
|
||||||
|
// out the substrate.
|
||||||
|
//
|
||||||
|
// Runs against the real test DB (same `oxicloud_test` guard as the
|
||||||
|
// other integration_tests modules). Gated on `all(test, integration_tests)`
|
||||||
|
// so the module compiles ONLY during `cargo test --cfg integration_tests`:
|
||||||
|
// a plain library build strips it, keeping `#[test]`-only imports from
|
||||||
|
// tripping the unused-imports lint. Mirrors the pattern in
|
||||||
|
// `folder_service::cascade_hook_integration_tests`.
|
||||||
|
#[cfg(all(test, integration_tests))]
|
||||||
|
mod phase4_gate_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::application::ports::opaque_ports::OpaqueRepositoryPort;
|
||||||
|
use crate::infrastructure::repositories::pg::{
|
||||||
|
OpaquePgRepository, SessionPgRepository, UserPgRepository,
|
||||||
|
};
|
||||||
|
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
||||||
|
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||||
|
use crate::integration_test_support::{ensure_clean_test_db, test_db_url};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Assemble a minimal `AuthApplicationService` wired with the
|
||||||
|
/// real repos + a real (fast-KSF) password hasher against the
|
||||||
|
/// integration test DB. Only what `login()` and the Phase 4
|
||||||
|
/// gate touch — no lifecycle dispatcher, no magic-link repo,
|
||||||
|
/// no OIDC. Returns the service, a handle to the concrete
|
||||||
|
/// OPAQUE repo (so the test can call `mark_migrated` /
|
||||||
|
/// `clear_registration` directly), and the pool for seeding.
|
||||||
|
async fn build_service() -> (
|
||||||
|
AuthApplicationService,
|
||||||
|
Arc<OpaquePgRepository>,
|
||||||
|
Arc<sqlx::PgPool>,
|
||||||
|
Arc<Argon2PasswordHasher>,
|
||||||
|
) {
|
||||||
|
let pool = Arc::new(
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(4)
|
||||||
|
.connect(&test_db_url())
|
||||||
|
.await
|
||||||
|
.expect("connect to integration-test PostgreSQL"),
|
||||||
|
);
|
||||||
|
ensure_clean_test_db(&pool).await;
|
||||||
|
|
||||||
|
let user_repo = Arc::new(UserPgRepository::new(pool.clone()));
|
||||||
|
let session_repo = Arc::new(SessionPgRepository::new(pool.clone()));
|
||||||
|
// Fast Argon2 so this test finishes in ms rather than seconds.
|
||||||
|
// Real deployments run at the OXICLOUD_HASH_* values; the gate
|
||||||
|
// logic under test doesn't care about hash cost.
|
||||||
|
let hasher = Arc::new(Argon2PasswordHasher::new(8, 1, 1));
|
||||||
|
let token = Arc::new(JwtTokenService::new(
|
||||||
|
"test-secret-do-not-use-in-prod-minimum-32-chars".to_string(),
|
||||||
|
3600,
|
||||||
|
86400,
|
||||||
|
));
|
||||||
|
let opaque_repo = Arc::new(OpaquePgRepository::new(pool.clone()));
|
||||||
|
|
||||||
|
let svc = AuthApplicationService::new(
|
||||||
|
user_repo,
|
||||||
|
session_repo,
|
||||||
|
hasher.clone(),
|
||||||
|
token,
|
||||||
|
PathBuf::from("/tmp"),
|
||||||
|
)
|
||||||
|
.with_opaque_repo(opaque_repo.clone());
|
||||||
|
|
||||||
|
(svc, opaque_repo, pool, hasher)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed a user with a password hash + verified email — the
|
||||||
|
/// minimum shape `login()` accepts. `email_verified_at` set so
|
||||||
|
/// the (default-off) `require_verified_email` gate doesn't
|
||||||
|
/// interfere with the Phase 4 branch we're isolating.
|
||||||
|
async fn seed_user_with_password(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
hasher: &Argon2PasswordHasher,
|
||||||
|
email: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> uuid::Uuid {
|
||||||
|
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4();
|
||||||
|
let hash = hasher.hash_password(password).await.expect("hash password");
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
id, username, email, password_hash, role,
|
||||||
|
storage_quota_bytes, storage_used_bytes,
|
||||||
|
created_at, updated_at, active,
|
||||||
|
email_verified_at
|
||||||
|
) VALUES (
|
||||||
|
$1, NULL, $2, $3, 'user'::auth.userrole,
|
||||||
|
0, 0, NOW(), NOW(), TRUE, NOW()
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(email)
|
||||||
|
.bind(hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed test user");
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full Phase 4 gate lifecycle: legacy login works, marking
|
||||||
|
/// migrated flips subsequent legacy logins to the `OpaqueLoginRequired`
|
||||||
|
/// shape, and admin-reset (`clear_registration`) re-opens legacy.
|
||||||
|
///
|
||||||
|
/// The single test covers all three transitions so a regression
|
||||||
|
/// in any leg (missing `with_opaque_repo`, wrong error message,
|
||||||
|
/// clear-not-nulling-migrated-at) fails one assertion instead of
|
||||||
|
/// three separate tests reporting the same drift.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_flow_across_mark_migrated_and_clear_registration() {
|
||||||
|
let (svc, opaque_repo, pool, hasher) = build_service().await;
|
||||||
|
let email = format!("phase4-{}@example.invalid", uuid::Uuid::new_v4());
|
||||||
|
let user_id = seed_user_with_password(&pool, &hasher, &email, "s3cret-passphrase").await;
|
||||||
|
|
||||||
|
// Baseline — no envelope, no migration mark → legacy works.
|
||||||
|
svc.login(crate::application::dtos::user_dto::LoginDto {
|
||||||
|
username: email.clone(),
|
||||||
|
password: "s3cret-passphrase".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("baseline legacy login must succeed");
|
||||||
|
|
||||||
|
// Simulate a successful OPAQUE handshake landing.
|
||||||
|
opaque_repo
|
||||||
|
.mark_migrated(user_id)
|
||||||
|
.await
|
||||||
|
.expect("mark migrated");
|
||||||
|
|
||||||
|
// Now the Phase 4 gate fires: same credentials, same call,
|
||||||
|
// but AccessDenied with the exact message the handler layer
|
||||||
|
// remaps to `403 OpaqueLoginRequired`.
|
||||||
|
let refused = svc
|
||||||
|
.login(crate::application::dtos::user_dto::LoginDto {
|
||||||
|
username: email.clone(),
|
||||||
|
password: "s3cret-passphrase".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("legacy login must be refused post-migration");
|
||||||
|
assert_eq!(
|
||||||
|
refused.kind,
|
||||||
|
ErrorKind::AccessDenied,
|
||||||
|
"gate must return AccessDenied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
refused.message, "Password login refused: this account has migrated to OPAQUE",
|
||||||
|
"message must match what the handler remaps to `OpaqueLoginRequired` — \
|
||||||
|
change either both sides at once or the handler stops recognising it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrong password on a migrated user MUST return the same
|
||||||
|
// shape as any other wrong-password (`Invalid credentials`),
|
||||||
|
// NOT the OPAQUE-migrated message — the gate lives AFTER the
|
||||||
|
// password check specifically so an attacker without the
|
||||||
|
// password learns nothing about migration state.
|
||||||
|
let wrong = svc
|
||||||
|
.login(crate::application::dtos::user_dto::LoginDto {
|
||||||
|
username: email.clone(),
|
||||||
|
password: "wrong-password".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("wrong password must still fail");
|
||||||
|
assert_eq!(wrong.message, "Invalid credentials");
|
||||||
|
|
||||||
|
// Admin-side password reset (clear_registration NULLs
|
||||||
|
// opaque_migrated_at atomically) must re-open the fallback —
|
||||||
|
// otherwise the admin-reset user is locked out (envelope
|
||||||
|
// gone, gate still refusing).
|
||||||
|
opaque_repo
|
||||||
|
.clear_registration(user_id)
|
||||||
|
.await
|
||||||
|
.expect("clear registration");
|
||||||
|
svc.login(crate::application::dtos::user_dto::LoginDto {
|
||||||
|
username: email,
|
||||||
|
password: "s3cret-passphrase".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("legacy login must succeed again after admin clear_registration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user