diff --git a/crates/consolebook-server/tests/acknowledgments.rs b/crates/consolebook-server/tests/acknowledgments.rs index 2bc2454..2bf5b03 100644 --- a/crates/consolebook-server/tests/acknowledgments.rs +++ b/crates/consolebook-server/tests/acknowledgments.rs @@ -6,7 +6,7 @@ //! invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::acknowledgments::{self, AckRefusal, AttestedKind, TraineeAckKind}; use consolebook_server::capabilities::{self, Capability, RoleBundle}; @@ -17,11 +17,11 @@ use consolebook_server::programs::{ RecordType, ScaleDef, ScaleKind, VersionContent, }; use consolebook_server::training_sessions::{self, Disposition, SessionInput}; -use consolebook_server::{assignments, data_dir::DataDir, enrollments, setup, storage, users}; +use consolebook_server::{assignments, data_dir::DataDir, enrollments, setup, storage}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; /// Every rule off: an open draft seals directly, which keeps each test /// record cheap — acknowledgment behavior is independent of how the @@ -55,7 +55,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -72,34 +72,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -109,15 +86,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn notice_count(&self, user_id: i64, kind: &str) -> i64 { @@ -832,8 +800,8 @@ async fn acknowledgment_api_round_trip() { // A second, unfinalized draft stays out of the trainee's reach. let open_id = draft_for(&fx, s.jordan_id, s.enrollment_id, "2026-06-03").await; - let taylor = fx.login("taylor.api", PASSWORD).await; - let casey = fx.login("casey.api", PASSWORD).await; + let taylor = fx.login("taylor.api", support::password()).await; + let casey = fx.login("casey.api", support::password()).await; let (status, body) = request(fx.app(), "GET", "/api/my/records", Some(&taylor), None).await; assert_eq!(status, StatusCode::OK); diff --git a/crates/consolebook-server/tests/amendments.rs b/crates/consolebook-server/tests/amendments.rs index 10c44f9..1d3e681 100644 --- a/crates/consolebook-server/tests/amendments.rs +++ b/crates/consolebook-server/tests/amendments.rs @@ -6,7 +6,7 @@ //! contract raw. Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::acknowledgments::{self, TraineeAckKind}; use consolebook_server::amendments::{self, AmendRefusal}; @@ -20,13 +20,11 @@ use consolebook_server::programs::{ RecordType, ScaleDef, ScaleKind, VersionContent, }; use consolebook_server::training_sessions::{self, Disposition, SessionInput}; -use consolebook_server::{ - assignments, canonical, data_dir::DataDir, enrollments, setup, storage, users, -}; +use consolebook_server::{assignments, canonical, data_dir::DataDir, enrollments, setup, storage}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; const OPEN_POLICY: PolicyDef = PolicyDef { review_approved: false, @@ -63,7 +61,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -80,34 +78,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -117,15 +92,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn notice_count(&self, user_id: i64, kind: &str) -> i64 { @@ -863,8 +829,8 @@ async fn amendment_api_round_trip() { .await .expect("call") .expect("sealed"); - let casey = fx.login("casey.api", PASSWORD).await; - let taylor = fx.login("taylor.api", PASSWORD).await; + let casey = fx.login("casey.api", support::password()).await; + let taylor = fx.login("taylor.api", support::password()).await; let (status, body) = request( fx.app(), diff --git a/crates/consolebook-server/tests/auth_flows.rs b/crates/consolebook-server/tests/auth_flows.rs index 3d2665d..2abf628 100644 --- a/crates/consolebook-server/tests/auth_flows.rs +++ b/crates/consolebook-server/tests/auth_flows.rs @@ -12,7 +12,7 @@ use consolebook_server::data_dir::DataDir; use consolebook_server::users::{IssueRefusal, ResetOrigin}; use consolebook_server::{http, setup, storage, users}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -54,7 +54,7 @@ impl Fixture { "agency_name": "Example County Communications", "username": "avery.admin", "display_name": "Avery Admin", - "password": PASSWORD, + "password": support::password(), }), ) .await; @@ -138,7 +138,7 @@ async fn setup_login_session_lifecycle() { let fx = Fixture::new().await; fx.initialized().await; - let cookie = fx.login("avery.admin", PASSWORD).await; + let cookie = fx.login("avery.admin", support::password()).await; let response = get(fx.app(), "/api/auth/session", Some(&cookie)).await; assert_eq!(response.status(), StatusCode::OK); @@ -172,7 +172,7 @@ async fn setup_requires_valid_code() { "setup_code": "00000000000000000000000000000000", "agency_name": "Example County Communications", "username": "avery.admin", - "password": PASSWORD, + "password": support::password(), }), ) .await; @@ -188,7 +188,7 @@ async fn setup_code_is_single_use_and_setup_unrepeatable() { "setup_code": code, "agency_name": "Example County Communications", "username": "avery.admin", - "password": PASSWORD, + "password": support::password(), }); let response = post_json(fx.app(), "/api/setup", None, request.clone()).await; assert_eq!(response.status(), StatusCode::CREATED); @@ -222,7 +222,7 @@ async fn setup_rejects_expired_code() { "setup_code": code, "agency_name": "Example County Communications", "username": "avery.admin", - "password": PASSWORD, + "password": support::password(), }), ) .await; @@ -241,7 +241,7 @@ async fn setup_enforces_password_policy() { "setup_code": code, "agency_name": "Example County Communications", "username": "avery.admin", - "password": "short", + "password": support::too_short_password(), }), ) .await; @@ -255,23 +255,23 @@ async fn login_rejects_bad_password_and_unknown_user_identically() { let fx = Fixture::new().await; fx.initialized().await; - let wrong_password = post_json( + let wrong_attempt = post_json( fx.app(), "/api/auth/login", None, - json!({ "username": "avery.admin", "password": "wrong-password-1" }), + json!({ "username": "avery.admin", "password": support::wrong_password() }), ) .await; let unknown_user = post_json( fx.app(), "/api/auth/login", None, - json!({ "username": "nobody.here", "password": "wrong-password-1" }), + json!({ "username": "nobody.here", "password": support::wrong_password() }), ) .await; - assert_eq!(wrong_password.status(), StatusCode::UNAUTHORIZED); + assert_eq!(wrong_attempt.status(), StatusCode::UNAUTHORIZED); assert_eq!(unknown_user.status(), StatusCode::UNAUTHORIZED); - let a = body_json(wrong_password).await; + let a = body_json(wrong_attempt).await; let b = body_json(unknown_user).await; assert_eq!(a, b, "responses must not reveal whether the account exists"); } @@ -280,14 +280,14 @@ async fn login_rejects_bad_password_and_unknown_user_identically() { async fn login_is_case_insensitive_on_username() { let fx = Fixture::new().await; fx.initialized().await; - let _cookie = fx.login("AVERY.ADMIN", PASSWORD).await; + let _cookie = fx.login("AVERY.ADMIN", support::password()).await; } #[tokio::test] async fn expired_session_is_rejected() { let fx = Fixture::new().await; fx.initialized().await; - let cookie = fx.login("avery.admin", PASSWORD).await; + let cookie = fx.login("avery.admin", support::password()).await; sqlx::query("UPDATE session SET expires_at = 1") .execute(&fx.pool) @@ -311,7 +311,7 @@ async fn garbage_session_token_is_rejected() { async fn admin_issued_reset_revokes_sessions_and_rotates_password() { let fx = Fixture::new().await; fx.initialized().await; - let admin_cookie = fx.login("avery.admin", PASSWORD).await; + let admin_cookie = fx.login("avery.admin", support::password()).await; let response = post_json( fx.app(), @@ -324,7 +324,7 @@ async fn admin_issued_reset_revokes_sessions_and_rotates_password() { let body = body_json(response).await; let reset_code = body["reset_code"].as_str().expect("code").to_string(); - let new_password = "rotated-passphrase-2"; + let new_password = support::fresh_password(); let response = post_json( fx.app(), "/api/auth/reset", @@ -345,11 +345,11 @@ async fn admin_issued_reset_revokes_sessions_and_rotates_password() { fx.app(), "/api/auth/login", None, - json!({ "username": "avery.admin", "password": PASSWORD }), + json!({ "username": "avery.admin", "password": support::password() }), ) .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - let _cookie = fx.login("avery.admin", new_password).await; + let _cookie = fx.login("avery.admin", &new_password).await; // The code is single-use. let response = post_json( @@ -359,7 +359,7 @@ async fn admin_issued_reset_revokes_sessions_and_rotates_password() { json!({ "username": "avery.admin", "reset_code": reset_code, - "new_password": "another-passphrase-3", + "new_password": support::fresh_password(), }), ) .await; @@ -373,14 +373,14 @@ async fn issuing_reset_codes_requires_manage_users() { // A user with no capabilities (created directly; user management API is // a later slice). - let hash = consolebook_server::secrets::hash_password(PASSWORD).expect("hash"); + let hash = consolebook_server::secrets::hash_password(support::password()).expect("hash"); let mut conn = fx.pool.acquire().await.expect("conn"); users::create(&mut conn, "taylor.trainee", "Taylor Trainee", "", "", &hash) .await .expect("create user"); drop(conn); - let cookie = fx.login("taylor.trainee", PASSWORD).await; + let cookie = fx.login("taylor.trainee", support::password()).await; let response = post_json( fx.app(), "/api/auth/reset-codes", @@ -421,7 +421,7 @@ async fn expired_reset_code_is_refused() { &fx.pool, "avery.admin", &issued.code.raw, - "rotated-passphrase-2", + &support::fresh_password(), ) .await .expect("use"); @@ -439,16 +439,12 @@ async fn recovery_rescues_an_administrator_without_credentials() { .await .expect("recover") .expect("issued"); - let outcome = users::use_reset_code( - &fx.pool, - "avery.admin", - &issued.code.raw, - "recovered-passphrase-4", - ) - .await - .expect("use"); + let recovered = support::fresh_password(); + let outcome = users::use_reset_code(&fx.pool, "avery.admin", &issued.code.raw, &recovered) + .await + .expect("use"); assert_eq!(outcome, users::ResetOutcome::Done); - let _cookie = fx.login("avery.admin", "recovered-passphrase-4").await; + let _cookie = fx.login("avery.admin", &recovered).await; // The recovery is on the audit record. let kinds: Vec = sqlx::query_scalar("SELECT kind FROM audit_event ORDER BY id") @@ -462,7 +458,7 @@ async fn recovery_rescues_an_administrator_without_credentials() { async fn recovery_refuses_non_administrators_and_unknown_users() { let fx = Fixture::new().await; fx.initialized().await; - let hash = consolebook_server::secrets::hash_password(PASSWORD).expect("hash"); + let hash = consolebook_server::secrets::hash_password(support::password()).expect("hash"); let mut conn = fx.pool.acquire().await.expect("conn"); users::create(&mut conn, "taylor.trainee", "Taylor Trainee", "", "", &hash) .await diff --git a/crates/consolebook-server/tests/backup_copy.rs b/crates/consolebook-server/tests/backup_copy.rs index fc499d1..ab35369 100644 --- a/crates/consolebook-server/tests/backup_copy.rs +++ b/crates/consolebook-server/tests/backup_copy.rs @@ -4,7 +4,7 @@ use std::path::Path; use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE}; +use axum::http::header::COOKIE; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use tower::ServiceExt; @@ -15,7 +15,7 @@ use consolebook_server::capabilities::{self, TRAINER_BUNDLE}; use consolebook_server::data_dir::DataDir; use consolebook_server::{http, secrets, setup, storage, users}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { tmp: tempfile::TempDir, @@ -43,7 +43,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -278,7 +278,7 @@ async fn status_reports_unconfigured_behind_current_and_unreachable() { #[tokio::test] async fn only_administrators_read_backup_health() { let fx = Fixture::new().await; - let hash = secrets::hash_password(PASSWORD).expect("hash"); + let hash = secrets::hash_password(support::password()).expect("hash"); let mut conn = fx.pool.acquire().await.expect("conn"); let trainer = users::create(&mut conn, "jordan.trainer", "Jordan Trainer", "", "", &hash) .await @@ -297,31 +297,8 @@ async fn only_administrators_read_backup_health() { ); let app = http::router(http::AppState::new(fx.pool.clone()).with_backups(settings)); - let login = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": "avery.admin", "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("login"); - let cookie = login - .headers() - .get("set-cookie") - .expect("cookie") - .to_str() - .expect("ascii") - .split(';') - .next() - .expect("pair") - .to_owned(); + let session = support::login(app.clone(), "avery.admin", support::password()).await; + let cookie = format!("{}={session}", http::SESSION_COOKIE); let response = app .oneshot( Request::builder() diff --git a/crates/consolebook-server/tests/backup_restore.rs b/crates/consolebook-server/tests/backup_restore.rs index b052884..0d61c7a 100644 --- a/crates/consolebook-server/tests/backup_restore.rs +++ b/crates/consolebook-server/tests/backup_restore.rs @@ -5,6 +5,8 @@ use consolebook_server::data_dir::DataDir; use consolebook_server::serve_lock::ServeLock; use consolebook_server::{backup, restore, scheduler, setup, storage}; +mod support; + fn temp_data_dir() -> (tempfile::TempDir, DataDir) { let tmp = tempfile::tempdir().expect("create temp dir"); let data_dir = DataDir::new(tmp.path().join("data")); @@ -25,7 +27,7 @@ async fn initialized_db(data_dir: &DataDir) -> String { "Example County Communications", "avery.admin", "Avery Admin", - "invented-passphrase-1", + support::password(), ) .await .expect("initialize") diff --git a/crates/consolebook-server/tests/draft_review.rs b/crates/consolebook-server/tests/draft_review.rs index 2a8d2d9..81607d2 100644 --- a/crates/consolebook-server/tests/draft_review.rs +++ b/crates/consolebook-server/tests/draft_review.rs @@ -5,7 +5,7 @@ //! invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::draft_content::{self, DraftContent, NarrativeEntry, RatingEntry}; @@ -16,11 +16,11 @@ use consolebook_server::programs::{ RecordType, ScaleDef, ScaleKind, TaskDef, TransitionDef, TransitionKind, VersionContent, }; use consolebook_server::training_sessions::{self, SessionInput}; -use consolebook_server::{assignments, data_dir::DataDir, enrollments, setup, storage, users}; +use consolebook_server::{assignments, data_dir::DataDir, enrollments, setup, storage}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -45,7 +45,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -62,34 +62,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -99,15 +76,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn form_competency_id(&self, version_id: i64, competency: &str) -> i64 { @@ -806,9 +774,9 @@ async fn review_queue_and_api_round_trip() { .expect("call") .expect("submitted"); - let taylor = fx.login("taylor.trainee", PASSWORD).await; - let casey = fx.login("casey.coord", PASSWORD).await; - let jordan = fx.login("jordan.trainer", PASSWORD).await; + let taylor = fx.login("taylor.trainee", support::password()).await; + let casey = fx.login("casey.coord", support::password()).await; + let jordan = fx.login("jordan.trainer", support::password()).await; // The queue is gated and lists the submitted draft. let (status, _) = request(fx.app(), "GET", "/api/reviews/queue", Some(&taylor), None).await; diff --git a/crates/consolebook-server/tests/enrollments.rs b/crates/consolebook-server/tests/enrollments.rs index 1c71ef7..593f1bf 100644 --- a/crates/consolebook-server/tests/enrollments.rs +++ b/crates/consolebook-server/tests/enrollments.rs @@ -4,7 +4,7 @@ //! invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::data_dir::DataDir; @@ -18,8 +18,7 @@ use consolebook_server::{enrollments, setup, storage, users}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; -const TRAINEE_PASSWORD: &str = "trainee-passphrase-3"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -44,7 +43,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -61,30 +60,7 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } /// Creates a program with one published version of `content`; returns @@ -276,7 +252,8 @@ fn complete_content() -> VersionContent { #[tokio::test] async fn a_created_user_signs_in_through_the_reset_flow() { let fx = Fixture::new().await; - let admin = fx.login("avery.admin", PASSWORD).await; + let trainee_password = support::fresh_password(); + let admin = fx.login("avery.admin", support::password()).await; let (status, body) = request( fx.app(), @@ -309,18 +286,19 @@ async fn a_created_user_signs_in_through_the_reset_flow() { Some(serde_json::json!({ "username": "jordan.trainee", "reset_code": reset_code, - "new_password": TRAINEE_PASSWORD, + "new_password": &trainee_password, })), ) .await; assert_eq!(status, StatusCode::NO_CONTENT, "reset: {body}"); - let _trainee_cookie = fx.login("jordan.trainee", TRAINEE_PASSWORD).await; + let _trainee_cookie = fx.login("jordan.trainee", &trainee_password).await; } #[tokio::test] async fn user_creation_refusals_and_roster_gates() { let fx = Fixture::new().await; - let admin = fx.login("avery.admin", PASSWORD).await; + let trainee_password = support::fresh_password(); + let admin = fx.login("avery.admin", support::password()).await; users::create_with_reset_code( &fx.pool, fx.admin_id, @@ -350,7 +328,7 @@ async fn user_creation_refusals_and_roster_gates() { &fx.pool, "jordan.trainee", &issued.code.raw, - TRAINEE_PASSWORD + &trainee_password ) .await .expect("reset"), @@ -382,7 +360,7 @@ async fn user_creation_refusals_and_roster_gates() { .await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(body["error"], "username_invalid"); - let trainee = fx.login("jordan.trainee", TRAINEE_PASSWORD).await; + let trainee = fx.login("jordan.trainee", &trainee_password).await; let (status, body) = request( fx.app(), "POST", @@ -404,7 +382,7 @@ async fn user_creation_refusals_and_roster_gates() { #[tokio::test] async fn enrollment_pins_published_versions_only() { let fx = Fixture::new().await; - let admin = fx.login("avery.admin", PASSWORD).await; + let admin = fx.login("avery.admin", support::password()).await; let trainee = users::create_with_reset_code( &fx.pool, fx.admin_id, @@ -496,7 +474,7 @@ async fn enrollment_pins_published_versions_only() { #[tokio::test] async fn enrollment_refusals_and_pin_protection() { let fx = Fixture::new().await; - let admin = fx.login("avery.admin", PASSWORD).await; + let admin = fx.login("avery.admin", support::password()).await; let (program_id, version_id) = fx.published_program(&complete_content()).await; let trainee = users::create_with_reset_code( &fx.pool, diff --git a/crates/consolebook-server/tests/evaluation_drafts.rs b/crates/consolebook-server/tests/evaluation_drafts.rs index daa81fb..75fd5e0 100644 --- a/crates/consolebook-server/tests/evaluation_drafts.rs +++ b/crates/consolebook-server/tests/evaluation_drafts.rs @@ -5,7 +5,7 @@ //! Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::draft_content::{self, DraftContent, NarrativeEntry, RatingEntry}; @@ -17,13 +17,13 @@ use consolebook_server::programs::{ }; use consolebook_server::training_sessions::{self, Disposition, SessionInput}; use consolebook_server::{ - assignments, data_dir::DataDir, enrollments, session_membership, setup, storage, users, + assignments, data_dir::DataDir, enrollments, session_membership, setup, storage, }; use http_body_util::BodyExt; use sqlx::ConnectOptions; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -50,7 +50,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -68,34 +68,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -105,15 +82,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn publish(&self, content: &VersionContent) -> i64 { @@ -1171,8 +1139,8 @@ async fn submission_snapshots_and_freezes() { async fn drafts_api_round_trip() { let fx = Fixture::new().await; let s = seed(&fx).await; - let jordan = fx.login("jordan.trainer", PASSWORD).await; - let taylor = fx.login("taylor.trainee", PASSWORD).await; + let jordan = fx.login("jordan.trainer", support::password()).await; + let taylor = fx.login("taylor.trainee", support::password()).await; let (status, body) = request( fx.app(), @@ -1325,7 +1293,7 @@ async fn drafts_api_round_trip() { ) .await; assert_eq!(status, StatusCode::NO_CONTENT, "transfer: {body}"); - let rowan = fx.login("rowan.trainer", PASSWORD).await; + let rowan = fx.login("rowan.trainer", support::password()).await; let (status, body) = request( fx.app(), "POST", diff --git a/crates/consolebook-server/tests/example_program.rs b/crates/consolebook-server/tests/example_program.rs index cfbc16f..8a44919 100644 --- a/crates/consolebook-server/tests/example_program.rs +++ b/crates/consolebook-server/tests/example_program.rs @@ -10,7 +10,7 @@ use consolebook_server::data_dir::DataDir; use consolebook_server::program_export::{self, ImportRefusal, ImportTarget}; use consolebook_server::{programs, setup, storage}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; fn template() -> String { let path = @@ -34,7 +34,7 @@ async fn installation() -> (tempfile::TempDir, sqlx::SqlitePool, i64) { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") diff --git a/crates/consolebook-server/tests/export_stream_bytes.rs b/crates/consolebook-server/tests/export_stream_bytes.rs index e8bbcda..6398868 100644 --- a/crates/consolebook-server/tests/export_stream_bytes.rs +++ b/crates/consolebook-server/tests/export_stream_bytes.rs @@ -7,9 +7,9 @@ //! archives would differ — in the local header, in a data descriptor, or in //! the central directory — so the comparison is the contract. -use std::io::Write; - use consolebook_server::export_stream::EntryBuffer; +use std::io::Write; +mod support; fn options() -> zip::write::SimpleFileOptions { zip::write::SimpleFileOptions::default() @@ -478,7 +478,7 @@ async fn installed() -> (tempfile::TempDir, SqlitePool, i64, i64) { "Example County Communications", "avery.admin", "Avery Admin", - "invented-passphrase-1", + support::password(), ) .await .expect("initialize") @@ -1585,10 +1585,10 @@ fn archive_instant(bytes: &[u8]) -> i64 { /// Signs in over the live listener and returns the session cookie value. async fn live_login(addr: &std::net::SocketAddr, username: &str) -> String { let mut client = tokio::net::TcpStream::connect(addr).await.expect("connect"); - let body = format!("{{\"username\":\"{username}\",\"password\":\"invented-passphrase-1\"}}"); + let body = serde_json::json!({ "username": username, "password": support::password() }); let request = format!( "POST /api/auth/login HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() + body.to_string().len() ); tokio::io::AsyncWriteExt::write_all(&mut client, request.as_bytes()) .await diff --git a/crates/consolebook-server/tests/finalization.rs b/crates/consolebook-server/tests/finalization.rs index 39d7a67..0a88cd9 100644 --- a/crates/consolebook-server/tests/finalization.rs +++ b/crates/consolebook-server/tests/finalization.rs @@ -5,7 +5,7 @@ //! trip. Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::draft_content::{self, DraftContent, NarrativeEntry, RatingEntry}; @@ -17,13 +17,11 @@ use consolebook_server::programs::{ PolicyDef, RecordType, ScaleDef, ScaleKind, TaskDef, VersionContent, }; use consolebook_server::training_sessions::{self, SessionInput}; -use consolebook_server::{ - assignments, canonical, data_dir::DataDir, enrollments, setup, storage, users, -}; +use consolebook_server::{assignments, canonical, data_dir::DataDir, enrollments, setup, storage}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -48,7 +46,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -65,34 +63,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -102,15 +77,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn publish(&self, content: &VersionContent) -> i64 { @@ -830,8 +796,8 @@ async fn finalize_api_round_trip() { let most = fx .narrative_id(s.version_id, "Most acceptable performance.") .await; - let jordan = fx.login("jordan.api", PASSWORD).await; - let casey = fx.login("casey.api", PASSWORD).await; + let jordan = fx.login("jordan.api", support::password()).await; + let casey = fx.login("casey.api", support::password()).await; // Incomplete submission refuses over the API with the typed code. let (status, body) = request( diff --git a/crates/consolebook-server/tests/getting_started.rs b/crates/consolebook-server/tests/getting_started.rs index 576b815..0abcd4e 100644 --- a/crates/consolebook-server/tests/getting_started.rs +++ b/crates/consolebook-server/tests/getting_started.rs @@ -3,7 +3,7 @@ //! All fixtures are invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE}; +use axum::http::header::COOKIE; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use tower::ServiceExt; @@ -13,7 +13,7 @@ use consolebook_server::data_dir::DataDir; use consolebook_server::getting_started::{self, Progress, ProgressRefusal}; use consolebook_server::{http, secrets, setup, storage, users}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; const NOTHING_YET: Progress = Progress { coordinators: 0, @@ -50,7 +50,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -63,7 +63,7 @@ impl Fixture { } async fn person(&self, username: &str, bundle: &[capabilities::Capability]) -> i64 { - let hash = secrets::hash_password(PASSWORD).expect("hash"); + let hash = secrets::hash_password(support::password()).expect("hash"); let mut conn = self.pool.acquire().await.expect("conn"); let id = users::create(&mut conn, username, username, "", "", &hash) .await @@ -76,32 +76,8 @@ impl Fixture { async fn get_as(&self, username: &str) -> (StatusCode, serde_json::Value) { let app = http::router(http::AppState::new(self.pool.clone())); - let login = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("login"); - assert_eq!(login.status(), StatusCode::OK, "sign in as {username}"); - let cookie = login - .headers() - .get("set-cookie") - .expect("session cookie") - .to_str() - .expect("ascii") - .split(';') - .next() - .expect("pair") - .to_owned(); + let session = support::login(app.clone(), username, support::password()).await; + let cookie = format!("{}={session}", http::SESSION_COOKIE); let response = app .oneshot( Request::builder() diff --git a/crates/consolebook-server/tests/installation_export.rs b/crates/consolebook-server/tests/installation_export.rs index bf852ed..2a08cda 100644 --- a/crates/consolebook-server/tests/installation_export.rs +++ b/crates/consolebook-server/tests/installation_export.rs @@ -7,7 +7,7 @@ use std::io::{Cursor, Read, Write}; use axum::body::Body; -use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}; use axum::http::{HeaderMap, Request, StatusCode}; use consolebook_server::acknowledgments::{self, TraineeAckKind}; use consolebook_server::capabilities::RoleBundle; @@ -49,7 +49,7 @@ mod properties; #[path = "installation_export/verifier.rs"] mod verifier; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; /// 2026-09-01T19:00:00Z. const EXPORTED_AT: i64 = 1_788_289_200; @@ -84,7 +84,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -102,34 +102,11 @@ impl Fixture { } async fn login(&self, username: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, support::password()).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -139,15 +116,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn id(&self, sql: &'static str, bind: i64) -> i64 { diff --git a/crates/consolebook-server/tests/lifecycle.rs b/crates/consolebook-server/tests/lifecycle.rs index 0e75804..452fb38 100644 --- a/crates/consolebook-server/tests/lifecycle.rs +++ b/crates/consolebook-server/tests/lifecycle.rs @@ -4,7 +4,7 @@ //! against the pinned transition graph. Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::data_dir::DataDir; @@ -15,11 +15,11 @@ use consolebook_server::programs::{ self, CompetencyDef, PhaseDef, PolicyDef, ScaleDef, ScaleKind, TaskDef, TransitionDef, TransitionKind, VersionContent, }; -use consolebook_server::{assignments, enrollments, notices, setup, storage, users}; +use consolebook_server::{assignments, enrollments, notices, setup, storage}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -44,7 +44,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -61,36 +61,13 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } /// Creates a user with `role`, sets a password through the standard /// reset flow, and returns their id. async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -100,15 +77,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } /// Publishes `content` as a new version of `program_id` (creating the @@ -255,7 +223,7 @@ fn phased_content() -> VersionContent { #[tokio::test] async fn role_bundles_grant_capabilities_and_profile_fields_list() { let fx = Fixture::new().await; - let admin = fx.login("avery.admin", PASSWORD).await; + let admin = fx.login("avery.admin", support::password()).await; // Role and profile fields ride user creation over the API. let (status, body) = request( @@ -282,12 +250,12 @@ async fn role_bundles_grant_capabilities_and_profile_fields_list() { Some(serde_json::json!({ "username": "casey.coordinator", "reset_code": reset_code, - "new_password": PASSWORD, + "new_password": support::password(), })), ) .await; assert_eq!(status, StatusCode::NO_CONTENT); - let coordinator = fx.login("casey.coordinator", PASSWORD).await; + let coordinator = fx.login("casey.coordinator", support::password()).await; let (status, body) = request( fx.app(), "GET", @@ -416,9 +384,9 @@ async fn assignments_scope_reads_notify_and_end() { // Scoped reads: the assigned trainer sees the enrollment; an // unassigned trainer and the capability-less trainee do not. - let jordan = fx.login("jordan.trainer", PASSWORD).await; - let other = fx.login("rowan.trainer", PASSWORD).await; - let taylor = fx.login("taylor.trainee", PASSWORD).await; + let jordan = fx.login("jordan.trainer", support::password()).await; + let other = fx.login("rowan.trainer", support::password()).await; + let taylor = fx.login("taylor.trainee", support::password()).await; let (status, body) = request( fx.app(), "GET", @@ -915,7 +883,7 @@ async fn phase_history_validates_graph_pause_and_effective_order() { // The API records completion and the detail tells the whole story, // with effective and recorded instants preserved separately. - let admin = fx.login("avery.admin", PASSWORD).await; + let admin = fx.login("avery.admin", support::password()).await; let (status, body) = request( fx.app(), "POST", diff --git a/crates/consolebook-server/tests/no_credential_literals.rs b/crates/consolebook-server/tests/no_credential_literals.rs new file mode 100644 index 0000000..615e0b2 --- /dev/null +++ b/crates/consolebook-server/tests/no_credential_literals.rs @@ -0,0 +1,887 @@ +//! Guard: no integration test declares its own credential literal (#126). +//! +//! Test credentials come from `tests/support/mod.rs`, which generates them +//! at run time. A credential written into a test file as a literal is what +//! static analysis reports as a hard-coded credential, and every new test +//! file that copied the pattern raised the same alerts again. This test +//! scans the test sources and fails on that pattern's return. +//! +//! # Scan contract +//! +//! **Scope.** Every `.rs` file under `crates/consolebook-server/tests/`, +//! recursively, including `support/` and this file. Symbolic links are not +//! followed (a linked file or directory is skipped): the tree's own files +//! are what the gate proves, and a link could loop or leave the tree. A +//! source that is not UTF-8 fails the scan, naming the file. +//! +//! **Literal.** A string literal token: `"…"`, raw `r"…"` / `r#"…"#` (any +//! number of `#`), and the byte and C forms `b"…"`, `br#"…"#`, `c"…"`, +//! `cr#"…"#`. Text inside comments is not code and is skipped; character +//! literals are not string literals. The empty literal `""` is never a +//! credential and is never flagged. A *literal expression* is a non-empty +//! literal as the first token of an expression, after any leading `&`, so +//! a method chain on it (`"…".to_owned()`) counts; `String::from(literal)` +//! counts too. +//! +//! **Sensitive name.** An identifier or key that contains `password`, +//! `passphrase`, or `secret`, ignoring case (`PASSWORD`, `new_password`, +//! `PHC_PASSWORD`, `current_password`, `client_secret`, …). +//! +//! **Flagged shapes.** +//! 1. *Binding*: `const`, `static`, or `let` (optionally `mut`) of a +//! sensitive name whose initializer, up to its `;`, holds any non-empty +//! literal: arrays, `vec![…]`, `Some(…)`, parentheses, `match` and `if` +//! arms, closures, `Into::into(…)`, `format!`, and `concat!` included. +//! 2. *Field*: `sensitive_name: literal expression` (a struct field). +//! 3. *JSON key*: a sensitive key literal followed by `:` and a literal +//! expression — the `json!` shape, matched wherever it appears. +//! 4. *Key pair*: a key literal right after `(` that ends in a sensitive +//! word, followed by `,` and a literal expression — form tuples +//! `("password", "…")` and `map.insert("password", "…")`. Pairs are +//! common data, so the key must open the pair and end in the word: +//! `("user.password_hash", "credential")` names a column, and a list +//! of words `["password", "secret"]` is not a pair. +//! 5. *Embedded JSON*: a literal whose text holds a sensitive JSON key +//! with a non-empty string value (`"password":"…"`, escaped once or +//! raw). A value that begins with `{` is a format interpolation; an +//! empty value, or one the literal ends before, is an absence check. +//! 6. *Call*: a literal expression passed to a function, method, or macro +//! (`name(…)`, `name!(…)`, `name::(…)`) +//! - as any argument when the callee's name is sensitive +//! (`hash_password`, `verify_password`, `check_password_policy`); +//! - as the last of two or more arguments to `initialize` or +//! `use_reset_code`, whose last parameter is the password; +//! - as the last argument when the callee's name contains `login`, +//! `log_in`, `sign_in`, `signin`, or `authenticate` (ignoring case) +//! and another argument is a literal too. A sign-in helper's last +//! parameter is its password when it takes one and its username when +//! it does not (`live_login(&addr, "avery.admin")`), so a lone +//! literal is a username; a username and a password both written in +//! are two. +//! +//! **Fails closed.** These rules read names, not meaning, so some +//! non-credentials are flagged: `let password_error = "…"`, `let +//! secret_kind = "…"`, a `PASSWORD_HASH_COLUMN` constant, a +//! placeholder such as `"password": ""`, `initialize("agency", +//! "tz")`, and `get_login_page(…, "/login", "text/html")`. Rename the +//! binding, field, or helper so it does not read as a credential. Do not +//! split or assemble strings to slip past the scan. +//! +//! **Allowlist.** Exact path, shape, and subject; each entry must still +//! match, so a stale entry fails too. Today there is one: +//! - `upgrade_compat.rs`, binding `PHC_PASSWORD`: the cross-version +//! compatibility fixture. `PHC_FROM_ARGON2_0_5` was hashed from this +//! exact password by the argon2 0.5 build; neither can be generated at +//! run time without losing what the test proves. +//! +//! The credential-absence property test +//! (`installation_export/properties.rs`) reads stored credential columns as +//! its positive control. It declares no literal, so it needs no entry. +//! +//! **Not caught, by design.** Each item has a case in +//! `no_credential_literals/cases.txt` that must stay unflagged, and every +//! `missed` case there names an item here, so this list and the code +//! cannot drift apart. Review covers these. +//! - *non-sensitive names*: `let pwd = "…"`, `const ADMIN_PW`, `let creds = +//! ("a", "…")`; the scan reads names, and these do not say what they hold. +//! - *destructuring*: `let (u, password) = (…)`; the binding rule reads a +//! single name, and a pattern can bind it anywhere. +//! - *reassignment*: `password = "…"` after `let mut password`; an +//! assignment has no keyword to anchor on, and `x = y` is everywhere. +//! - *Some in a field*: `Login { password: Some("…") }`; a field's value +//! is read as one literal expression, not searched. +//! - *index assignment*: `body["password"] = "…"`; the key is not followed +//! by `:` or `,`, and indexing is mostly reads. +//! - *double-escaped JSON*: `"{\\\"password\\\":…}"`; JSON escaped inside +//! JSON text is read once, as written in the source. +//! - *brace-led JSON value*: `"password":"{…}"` in a literal reads as a +//! format interpolation. +//! - *non-English or fullwidth names*: `contraseña`, `password`; the +//! sensitive words are the English ASCII ones the tests use. +//! - *values built by a macro*: `"password": format!(…)`, `login(app, "a", +//! concat!(…))`; an argument or value is read as one literal expression. +//! - *included file contents*: `hash_password(include_str!("…"))`; the +//! included file is not Rust source, so it is not scanned. +//! - *username variable beside a literal password*: `login(app, username, +//! "…")`; one literal in a sign-in call is taken as the username. +//! - *constructors and other helpers*: `LoginRequest::new("a", "…")`, +//! `SignIn::new(…)`; only the callee's own name is read. +//! - *non-.rs files*: `tests/fixtures/` and this guard's case corpus are +//! data, not code (tree-level; checked by +//! `the_walk_skips_links_and_data_and_names_bad_files`). +//! - *symbolic links*: see **Scope** (tree-level; checked by the same test). +//! +//! Findings name the file, line, shape, and subject (the name, key, or +//! callee), never the literal's text. + +use std::fmt; +use std::path::{Path, PathBuf}; + +const SENSITIVE: [&str; 3] = ["password", "passphrase", "secret"]; + +/// Callees whose last parameter is the password. +const LAST_ARGUMENT_CALLEES: [&str; 2] = ["initialize", "use_reset_code"]; +/// Sign-in helpers: the last argument is the password when another +/// argument is a literal too. +const SIGN_IN_FRAGMENTS: [&str; 5] = ["login", "log_in", "sign_in", "signin", "authenticate"]; + +/// The case corpus: synthetic sources and what the scan must find in each. +const CASES: &str = include_str!("no_credential_literals/cases.txt"); +/// This file, for the header's list of what is not caught. +const THIS_FILE: &str = include_str!("no_credential_literals.rs"); +/// Not-caught items proved on a tree rather than by a corpus case. +const TREE_LEVEL_MISSES: [&str; 2] = ["non-.rs files", "symbolic links"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Shape { + Binding, + Field, + JsonKey, + KeyPair, + EmbeddedJson, + Call, +} + +impl Shape { + fn parse(name: &str) -> Self { + match name { + "Binding" => Self::Binding, + "Field" => Self::Field, + "JsonKey" => Self::JsonKey, + "KeyPair" => Self::KeyPair, + "EmbeddedJson" => Self::EmbeddedJson, + "Call" => Self::Call, + other => panic!("unknown shape {other} in cases.txt"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Finding { + path: String, + line: usize, + shape: Shape, + subject: String, +} + +impl fmt::Display for Finding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}: {:?} `{}`", + self.path, self.line, self.shape, self.subject + ) + } +} + +struct Allowed { + path: &'static str, + shape: Shape, + subject: &'static str, + reason: &'static str, +} + +const ALLOWLIST: [Allowed; 1] = [Allowed { + path: "upgrade_compat.rs", + shape: Shape::Binding, + subject: "PHC_PASSWORD", + reason: "cross-version fixture: PHC_FROM_ARGON2_0_5 was hashed from it by argon2 0.5", +}]; + +impl Allowed { + fn covers(&self, finding: &Finding) -> bool { + finding.path == self.path && finding.shape == self.shape && finding.subject == self.subject + } +} + +// ----------------------------------------------------------------- lexer + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Tok { + Ident(String), + Str(String), + Punct(char), + Other, +} + +#[derive(Debug, Clone)] +struct Token { + tok: Tok, + line: usize, +} + +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' +} + +fn is_ident_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// A string literal's prefix at `at`: (characters before the hashes, +/// raw?). `r"`, `r#"`, `br"`, `cr#"`, `b"`, `c"`. +fn string_prefix(chars: &[char], at: usize) -> Option<(usize, bool)> { + let rest = |n: usize| chars.get(at + n).copied(); + let raw_after = |n: usize| { + let mut k = n; + while rest(k) == Some('#') { + k += 1; + } + rest(k) == Some('"') + }; + match (rest(0), rest(1)) { + (Some('r'), _) if raw_after(1) => Some((1, true)), + (Some('b' | 'c'), Some('r')) if raw_after(2) => Some((2, true)), + (Some('b' | 'c'), Some('"')) => Some((1, false)), + _ => None, + } +} + +fn tokenize(src: &str) -> Vec { + let chars: Vec = src.chars().collect(); + let mut tokens = Vec::new(); + let (mut at, mut line) = (0, 1); + while at < chars.len() { + let (tok, end) = lex(&chars, at); + if let Some(tok) = tok { + tokens.push(Token { tok, line }); + } + line += chars[at..end].iter().filter(|c| **c == '\n').count(); + at = end; + } + tokens +} + +/// The token that starts at `at` (none for whitespace and comments) and +/// the index just past it. +fn lex(chars: &[char], at: usize) -> (Option, usize) { + let c = chars[at]; + let next = chars.get(at + 1).copied(); + let ident_end = |from: usize| { + let mut i = from; + while i < chars.len() && is_ident_char(chars[i]) { + i += 1; + } + i + }; + if c.is_whitespace() { + (None, at + 1) + } else if c == '/' && next == Some('/') { + let end = chars[at..].iter().position(|c| *c == '\n'); + (None, end.map_or(chars.len(), |n| at + n)) + } else if c == '/' && next == Some('*') { + (None, block_comment_end(chars, at)) + } else if let Some((skip, raw)) = string_prefix(chars, at) { + let (text, end) = if raw { + raw_string(chars, at + skip) + } else { + quoted_string(chars, at + skip) + }; + (Some(Tok::Str(text)), end) + } else if c == '"' { + let (text, end) = quoted_string(chars, at); + (Some(Tok::Str(text)), end) + } else if c == 'r' && next == Some('#') && chars.get(at + 2).is_some_and(|c| is_ident_start(*c)) + { + // A raw identifier, `r#name`. + let end = ident_end(at + 2); + (Some(Tok::Ident(chars[at + 2..end].iter().collect())), end) + } else if is_ident_start(c) { + let end = ident_end(at); + (Some(Tok::Ident(chars[at..end].iter().collect())), end) + } else if c.is_ascii_digit() { + (Some(Tok::Other), ident_end(at)) + } else if c == '\'' { + // A character literal (`'x'`, `'\n'`, `'\u{..}'`) or a lifetime. + let end = if next == Some('\\') { + let close = chars[at + 2..].iter().skip(1).position(|c| *c == '\''); + close.map_or(chars.len(), |n| at + 4 + n) + } else if chars.get(at + 2) == Some(&'\'') { + at + 3 + } else { + ident_end(at + 1) + }; + (Some(Tok::Other), end.min(chars.len())) + } else { + (Some(Tok::Punct(c)), at + 1) + } +} + +/// The index just past the (nested) block comment that opens at `at`. +fn block_comment_end(chars: &[char], at: usize) -> usize { + let (mut i, mut depth) = (at, 0usize); + while i < chars.len() { + if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + depth += 1; + i += 2; + } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + depth -= 1; + i += 2; + if depth == 0 { + return i; + } + } else { + i += 1; + } + } + chars.len() +} + +/// `"…"` starting at the opening quote; returns its text and the index +/// after the closing quote. +fn quoted_string(chars: &[char], open: usize) -> (String, usize) { + let mut i = open + 1; + while i < chars.len() && chars[i] != '"' { + i += if chars[i] == '\\' { 2 } else { 1 }; + } + let end = i.min(chars.len()); + ( + chars[open + 1..end].iter().collect(), + (i + 1).min(chars.len()), + ) +} + +/// `#…#"…"#…#` starting at the first `#` or quote after the `r`. +fn raw_string(chars: &[char], at: usize) -> (String, usize) { + let mut hashes = 0; + while chars.get(at + hashes) == Some(&'#') { + hashes += 1; + } + let body = at + hashes + 1; + let mut i = body; + while i < chars.len() { + if chars[i] == '"' && (1..=hashes).all(|k| chars.get(i + k) == Some(&'#')) { + return (chars[body..i].iter().collect(), i + 1 + hashes); + } + i += 1; + } + (chars[body.min(chars.len())..].iter().collect(), chars.len()) +} + +// ----------------------------------------------------------------- rules + +fn sensitive(name: &str) -> bool { + let lower = name.to_lowercase(); + SENSITIVE.iter().any(|word| lower.contains(word)) +} + +fn ends_sensitive(key: &str) -> bool { + let lower = key.to_lowercase(); + SENSITIVE.iter().any(|word| lower.ends_with(word)) +} + +fn ident(tokens: &[Token], at: usize) -> Option<&str> { + match tokens.get(at).map(|t| &t.tok) { + Some(Tok::Ident(name)) => Some(name), + _ => None, + } +} + +fn punct(tokens: &[Token], at: usize, c: char) -> bool { + tokens.get(at).map(|t| &t.tok) == Some(&Tok::Punct(c)) +} + +/// The line of a non-empty literal token at `at`. +fn non_empty_literal(tokens: &[Token], at: usize) -> Option { + match tokens.get(at) { + Some(Token { + tok: Tok::Str(text), + line, + }) if !text.is_empty() => Some(*line), + _ => None, + } +} + +/// The line of the literal expression starting at `at`, if one does. +fn literal_expression(tokens: &[Token], mut at: usize) -> Option { + while punct(tokens, at, '&') { + at += 1; + } + let from = ident(tokens, at) == Some("String") + && punct(tokens, at + 1, ':') + && punct(tokens, at + 2, ':') + && ident(tokens, at + 3) == Some("from") + && punct(tokens, at + 4, '('); + if from { + return literal_expression(tokens, at + 5); + } + non_empty_literal(tokens, at) +} + +/// The line of the first non-empty literal in the initializer that starts +/// at `at` and ends at its `;` (or where its enclosing block closes). +fn literal_in_initializer(tokens: &[Token], mut at: usize) -> Option { + let mut depth = 0usize; + while let Some(token) = tokens.get(at) { + match token.tok { + Tok::Punct('(' | '[' | '{') => depth += 1, + Tok::Punct(')' | ']' | '}') => { + if depth == 0 { + return None; + } + depth -= 1; + } + Tok::Punct(';') if depth == 0 => return None, + _ => { + if let Some(line) = non_empty_literal(tokens, at) { + return Some(line); + } + } + } + at += 1; + } + None +} + +/// The `(` (or `[`) that opens the call of the callee at `at`: `name(`, +/// `name!(`, `name![`, or `name::<…>(`. +fn call_open(tokens: &[Token], at: usize) -> Option { + if punct(tokens, at + 1, '(') { + return Some(at + 1); + } + if punct(tokens, at + 1, '!') && (punct(tokens, at + 2, '(') || punct(tokens, at + 2, '[')) { + return Some(at + 2); + } + if punct(tokens, at + 1, ':') && punct(tokens, at + 2, ':') && punct(tokens, at + 3, '<') { + let mut depth = 0usize; + let mut close = at + 3; + while close < tokens.len() { + if punct(tokens, close, '<') { + depth += 1; + } else if punct(tokens, close, '>') { + depth -= 1; + if depth == 0 { + break; + } + } + close += 1; + } + return punct(tokens, close + 1, '(').then_some(close + 1); + } + None +} + +/// The start index of each argument of the call whose `(` is at `open`. +fn arguments(tokens: &[Token], open: usize) -> Vec { + let mut starts = Vec::new(); + let mut depth = 0usize; + let mut at = open; + while let Some(token) = tokens.get(at) { + match token.tok { + Tok::Punct('(' | '[' | '{') => { + depth += 1; + if depth == 1 { + starts.push(at + 1); + } + } + Tok::Punct(')' | ']' | '}') => { + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } + Tok::Punct(',') if depth == 1 => starts.push(at + 1), + _ => {} + } + at += 1; + } + // A trailing comma leaves an empty last argument. + starts.retain(|start| { + !(punct(tokens, *start, ')') || punct(tokens, *start, ']') || *start >= tokens.len()) + }); + starts +} + +/// The arguments of the call at `open` that the call rule reads. +fn checked_arguments(tokens: &[Token], callee: &str, open: usize) -> Vec { + let args = arguments(tokens, open); + if sensitive(callee) { + return args; + } + let lower = callee.to_lowercase(); + let literals = args + .iter() + .filter(|start| literal_expression(tokens, **start).is_some()) + .count(); + let last_is_password = args.len() >= 2 + && (LAST_ARGUMENT_CALLEES.contains(&callee) + || (SIGN_IN_FRAGMENTS.iter().any(|f| lower.contains(f)) && literals >= 2)); + if last_is_password { + args[args.len() - 1..].to_vec() + } else { + Vec::new() + } +} + +/// The text of a literal holds a sensitive JSON key with a non-empty +/// string value that is not a format interpolation. Every quote is tried +/// as a key's opening quote, so a quote earlier in the text cannot pair +/// with the wrong one and hide a key. +fn embedded_json_key(text: &str) -> Option { + let text = text.replace("\\\"", "\""); + let mut rest = text.as_str(); + while let Some(open) = rest.find('"') { + rest = &rest[open + 1..]; + let Some(close) = rest.find('"') else { break }; + let key = &rest[..close]; + if sensitive(key) + && let Some(value) = rest[close + 1..].trim_start().strip_prefix(':') + && let Some(value) = value.trim_start().strip_prefix('"') + && !(value.is_empty() || value.starts_with(['{', '"'])) + { + return Some(key.to_owned()); + } + } + None +} + +fn scan_source(path: &str, src: &str) -> Vec { + let tokens = tokenize(src); + let mut findings = Vec::new(); + let mut flag = |line: usize, shape: Shape, subject: &str| { + findings.push(Finding { + path: path.to_owned(), + line, + shape, + subject: subject.to_owned(), + }); + }; + for (at, token) in tokens.iter().enumerate() { + match &token.tok { + Tok::Ident(keyword) if ["const", "static", "let"].contains(&keyword.as_str()) => { + let mut name_at = at + 1; + if ident(&tokens, name_at) == Some("mut") { + name_at += 1; + } + let Some(name) = ident(&tokens, name_at).filter(|name| sensitive(name)) else { + continue; + }; + let mut eq = name_at + 1; + while eq < tokens.len() && !punct(&tokens, eq, '=') && !punct(&tokens, eq, ';') { + eq += 1; + } + if punct(&tokens, eq, '=') + && let Some(line) = literal_in_initializer(&tokens, eq + 1) + { + flag(line, Shape::Binding, name); + } + } + Tok::Ident(name) + if sensitive(name) + && punct(&tokens, at + 1, ':') + && !punct(&tokens, at + 2, ':') + && (at == 0 || !punct(&tokens, at - 1, ':')) => + { + if let Some(line) = literal_expression(&tokens, at + 2) { + flag(line, Shape::Field, name); + } + } + Tok::Ident(callee) => { + if at > 0 && ident(&tokens, at - 1) == Some("fn") { + continue; + } + let Some(open) = call_open(&tokens, at) else { + continue; + }; + for start in checked_arguments(&tokens, callee, open) { + if let Some(line) = literal_expression(&tokens, start) { + flag(line, Shape::Call, callee); + } + } + } + Tok::Str(text) => { + if sensitive(text) + && punct(&tokens, at + 1, ':') + && let Some(line) = literal_expression(&tokens, at + 2) + { + flag(line, Shape::JsonKey, text); + } + if ends_sensitive(text) + && at > 0 + && punct(&tokens, at - 1, '(') + && punct(&tokens, at + 1, ',') + && let Some(line) = literal_expression(&tokens, at + 2) + { + flag(line, Shape::KeyPair, text); + } + if let Some(key) = embedded_json_key(text) { + flag(token.line, Shape::EmbeddedJson, &key); + } + } + _ => {} + } + } + findings +} + +// ------------------------------------------------------------------ walk + +/// Every `.rs` file under `dir`, recursively, never following a link. +fn rust_files(dir: &Path, out: &mut Vec) { + let mut entries: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("read {}: {err}", dir.display())) + .map(|entry| entry.expect("directory entry").path()) + .collect(); + entries.sort(); + for path in entries { + let kind = std::fs::symlink_metadata(&path) + .unwrap_or_else(|err| panic!("stat {}: {err}", path.display())) + .file_type(); + if kind.is_symlink() { + continue; + } + if kind.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } +} + +/// Every finding under `root`, with paths relative to it, and the files +/// scanned. +fn scan_tree(root: &Path) -> (Vec, Vec) { + let mut files = Vec::new(); + rust_files(root, &mut files); + let mut findings = Vec::new(); + let mut scanned = Vec::new(); + for file in files { + let relative = file.strip_prefix(root).expect("under root"); + let label = relative + .components() + .map(|part| part.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + let src = std::fs::read_to_string(&file) + .unwrap_or_else(|err| panic!("read test source {label}: {err}")); + findings.extend(scan_source(&label, &src)); + scanned.push(label); + } + (findings, scanned) +} + +fn not_allowlisted(findings: Vec) -> Vec { + findings + .into_iter() + .filter(|finding| !ALLOWLIST.iter().any(|allowed| allowed.covers(finding))) + .collect() +} + +fn listing(findings: &[Finding]) -> String { + findings + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") +} + +// ----------------------------------------------------------------- cases + +/// What a corpus case expects. +enum Expect { + /// Exactly these findings (none: the source scans clean). + Findings(Vec<(Shape, String, usize)>), + /// No finding, and the header's not-caught list names this item. + Missed(String), + /// Malformed source: the scan only has to finish. + Robust, +} + +struct Case { + id: String, + expect: Expect, + source: String, +} + +/// `=== findings [Shape:subject:line,…]`, `=== missed `, +/// or `=== robust`, each followed by its source. Lines before the +/// first case are the corpus's own notes. +fn cases() -> Vec { + let mut cases: Vec = Vec::new(); + for line in CASES.split_inclusive('\n') { + let Some(spec) = line.strip_prefix("=== ") else { + if let Some(case) = cases.last_mut() { + case.source.push_str(line); + } + continue; + }; + let spec = spec.trim_end(); + let (id, rest) = spec.split_once(' ').expect("case id and kind"); + let (kind, arg) = rest.split_once(' ').unwrap_or((rest, "")); + let expect = match kind { + "findings" => Expect::Findings( + arg.split(',') + .filter(|item| !item.is_empty()) + .map(|item| { + let mut parts = item.split(':'); + let shape = Shape::parse(parts.next().expect("shape")); + let subject = parts.next().expect("subject").to_owned(); + let line = parts.next().expect("line").parse().expect("line number"); + (shape, subject, line) + }) + .collect(), + ), + "missed" => Expect::Missed(arg.to_owned()), + "robust" => Expect::Robust, + other => panic!("case {id}: unknown kind {other}"), + }; + cases.push(Case { + id: id.to_owned(), + expect, + source: String::new(), + }); + } + cases +} + +/// The items of the header's **Not caught** list: `//! - *item*: …`. +fn not_caught_items() -> Vec { + let section = THIS_FILE + .split("**Not caught, by design.**") + .nth(1) + .expect("not-caught section") + .split("\n\n") + .next() + .expect("section text"); + section + .lines() + .filter_map(|line| line.strip_prefix("//! - *")) + .map(|item| item.split_once("*:").expect("item label").0.to_owned()) + .collect() +} + +// ----------------------------------------------------------------- tests + +#[test] +fn no_test_declares_its_own_credential_literal() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); + let (findings, scanned) = scan_tree(&root); + // The walk is real: it reaches nested modules and this file. + for expected in [ + "installation_export/properties.rs", + "support/mod.rs", + "write_transactions/accounts.rs", + "no_credential_literals.rs", + ] { + assert!(scanned.iter().any(|s| s == expected), "scanned {expected}"); + } + for allowed in &ALLOWLIST { + assert!( + findings.iter().any(|finding| allowed.covers(finding)), + "stale allowlist entry {} `{}` ({})", + allowed.path, + allowed.subject, + allowed.reason + ); + } + let remaining = not_allowlisted(findings); + assert!( + remaining.is_empty(), + "test credentials come from `tests/support` (`support::password()` and friends); \ + these declare their own:\n{}", + listing(&remaining) + ); +} + +/// Every case in the corpus scans as it says: forbidden shapes are found +/// at their file and line, allowed shapes scan clean, malformed sources +/// finish, and each documented miss stays unflagged. +#[test] +fn the_case_corpus_scans_as_written() { + let cases = cases(); + assert!(cases.len() > 100, "{} cases", cases.len()); + let documented = not_caught_items(); + for case in &cases { + let path = format!("cases.txt#{}", case.id); + let found = scan_source(&path, &case.source); + match &case.expect { + Expect::Findings(expected) => { + let expected: Vec = expected + .iter() + .map(|(shape, subject, line)| Finding { + path: path.clone(), + line: *line, + shape: *shape, + subject: subject.clone(), + }) + .collect(); + assert_eq!(found, expected, "case {}", case.id); + } + Expect::Missed(item) => { + assert!(found.is_empty(), "case {}:\n{}", case.id, listing(&found)); + assert!( + documented.contains(item), + "case {}: `{item}` is not in the header's not-caught list", + case.id + ); + } + Expect::Robust => {} + } + } + // Every not-caught item has a case, or is proved on a tree. + for item in &documented { + let has_case = cases + .iter() + .any(|case| matches!(&case.expect, Expect::Missed(m) if m == item)); + assert!( + has_case || TREE_LEVEL_MISSES.contains(&item.as_str()), + "not-caught item `{item}` has no `missed` case" + ); + } +} + +/// The allowlist is by exact path, shape, and subject: the fixture is +/// covered where it lives and nowhere else, and nothing else in that file +/// is. +#[test] +fn the_allowlist_covers_only_the_named_fixture() { + let fixture = r#"const PHC_PASSWORD: &'static str = "x";"#; + assert_eq!( + not_allowlisted(scan_source("upgrade_compat.rs", fixture)), + [] + ); + assert_eq!( + not_allowlisted(scan_source("other.rs", fixture)).len(), + 1, + "another file" + ); + assert_eq!( + not_allowlisted(scan_source("nested/upgrade_compat.rs", fixture)).len(), + 1, + "another path" + ); + let second = r#"const OTHER_PASSWORD: &'static str = "x";"#; + assert_eq!( + not_allowlisted(scan_source("upgrade_compat.rs", second)).len(), + 1, + "another subject" + ); +} + +/// The walk scans `.rs` files in nested directories, skips data files and +/// symbolic links, and names a source it cannot read. +#[cfg(unix)] +#[test] +fn the_walk_skips_links_and_data_and_names_bad_files() { + let forbidden = r#"const PASSWORD: &'static str = "x";"#; + let tree = tempfile::tempdir().expect("temp dir"); + let outside = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir(tree.path().join("nested")).expect("mkdir"); + std::fs::write(tree.path().join("nested/real.rs"), forbidden).expect("write"); + std::fs::write(tree.path().join("data.txt"), forbidden).expect("write"); + std::fs::write(outside.path().join("linked.rs"), forbidden).expect("write"); + std::os::unix::fs::symlink(outside.path(), tree.path().join("linked_dir")).expect("link"); + std::os::unix::fs::symlink( + outside.path().join("linked.rs"), + tree.path().join("linked_file.rs"), + ) + .expect("link"); + let (findings, scanned) = scan_tree(tree.path()); + assert_eq!(scanned, ["nested/real.rs"]); + assert_eq!(listing(&findings), "nested/real.rs:1: Binding `PASSWORD`"); + + std::fs::write(tree.path().join("broken.rs"), [0xff, 0xfe, b'\n']).expect("write"); + let panic = std::panic::catch_unwind(|| scan_tree(tree.path())).expect_err("non-UTF-8 fails"); + let message = panic.downcast_ref::().cloned().unwrap_or_default(); + assert!(message.contains("broken.rs"), "{message}"); +} diff --git a/crates/consolebook-server/tests/no_credential_literals/cases.txt b/crates/consolebook-server/tests/no_credential_literals/cases.txt new file mode 100644 index 0000000..9b9b3a2 --- /dev/null +++ b/crates/consolebook-server/tests/no_credential_literals/cases.txt @@ -0,0 +1,345 @@ +Synthetic sources for tests/no_credential_literals.rs. Data, not code: +the guard scans only .rs files. Every value here is invented. +Case lines: "=== findings [Shape:subject:line,...]" (exactly these; +none means clean), "=== missed ", "=== robust". +FN*, FP*, and R* cases are the independent review's probes for #126; +FP* cases that expect findings are the documented fail-closed set. + +=== own-const findings Binding:PASSWORD:1 +const PASSWORD: &str = "x"; +=== own-static findings Binding:ADMIN_SECRET:1 +static ADMIN_SECRET: &str = "x"; +=== own-let findings Binding:new_password:1 +let new_password = "x"; +=== own-let-mut-chain findings Binding:passphrase:1 +let mut passphrase: String = "x".to_owned(); +=== own-string-from findings Binding:password:1 +let password = String::from("x"); +=== own-raw-hash findings Binding:PASSWORD:1 +const PASSWORD: &str = r#"x"#; +=== own-raw findings Binding:password:1 +let password = r"x"; +=== own-char-quotes findings Binding:password:1 +let q = '"'; let e = '\''; let password = "x"; +=== own-field findings Field:password:1 +Login { password: "x" } +=== own-json findings JsonKey:password:1 +json!({ "password": "x" }) +=== own-json-new findings JsonKey:new_password:1 +json!({ "new_password": "x" }) +=== own-json-raw findings JsonKey:current_password:1 +json!({ "current_password": r#"x"# }) +=== own-embedded findings EmbeddedJson:password:1 +let body = "{\"password\":\"x\"}"; +=== own-hash findings Call:hash_password:1 +secrets::hash_password("x") +=== own-verify findings Call:verify_password:1 +secrets::verify_password("x", &phc) +=== own-login findings Call:login:1 +fx.login("avery.admin", "x") +=== own-sign-in findings Call:sign_in:1 +support::sign_in(app, "avery.admin", &"x".to_owned()) +=== own-reset findings Call:use_reset_code:1 +users::use_reset_code(&pool, "a", &code, "x") +=== own-initialize findings Call:initialize:1 +setup::initialize(&p, &c, "A", "a", "A", r"x",) +=== own-multiline findings Binding:PASSWORD:6 +let a = "one +two"; +/* " */ +let b = r#"""#; +const PASSWORD: &str = + "x"; +=== FN01 missed non-sensitive names +let creds = ("avery.admin", "pw-literal-1234"); +let cookie = login(app, creds.0, creds.1).await; +=== FN01b missed destructuring +let (username, password) = ("avery.admin", "pw-literal-1234"); +=== FN02a missed values built by a macro +let body = serde_json::json!({ "password": format!("x") }); +=== FN02b findings JsonKey:password:1 +let body = serde_json::json!({ "password": "pw-literal-1234" }); +=== FN03 findings KeyPair:password:1 +client.post(url).form(&[("username", "a"), ("password", "lit")]); +=== FN04a findings Binding:bad_passwords:1 +let bad_passwords = vec!["short", "alllowercase"]; +=== FN04b findings Binding:bad_passwords:1 +let bad_passwords = ["short", "alllowercase"]; +=== FN04c missed non-sensitive names +for candidate in ["short", "alllowercase"] { check_password_policy(candidate); } +=== FN04d missed non-sensitive names +let accounts = [("avery.admin", "pw-literal-1234"), ("casey", "pw-two")]; +=== FN05a findings Binding:password:1 +let password = concat!("pw-", "literal"); +=== FN05b missed values built by a macro +login(app, "avery.admin", concat!("pw", "1")).await; +=== FN06 findings Binding:password:1 +let password = &*"pw-literal-1234".to_string(); +=== FN06b findings Binding:password:1 +let password = ("pw-literal-1234"); +=== FN06c findings Binding:password:1 +let password = Some("pw-literal-1234"); +=== FN06d missed Some in a field +Credentials { username: "a", password: Some("pw-literal") } +=== FN06e findings Binding:password:1 +let password: String = Into::into("pw-literal-1234"); +=== FN07 findings Binding:password:1 +let password = match role { Role::Admin => "pw-admin-1234", _ => "pw-other-1234" }; +=== FN07b findings Binding:password:1 +let password = if admin { "pw-admin-1234" } else { "pw-other-1234" }; +=== FN08a findings JsonKey:password:4 +let body = json!({ + "username": "avery.admin", + "password": + "pw-literal-1234", +}); +=== FN08b findings Call:login:4 +let cookie = support::login( + app.clone(), + "avery.admin", + "pw-literal-1234", +) +.await; +=== FN09a findings Call:login:1 +let cookie = login!(app, "avery.admin", "pw-literal-1234"); +=== FN09b findings Call:hash_password:1 +let hash = hash_password!("pw-literal-1234"); +=== FN09c findings Call:login:1 +let cookie = login::(app, "avery.admin", "pw-literal-1234").await; +=== FN11 findings EmbeddedJson:password:1 +let body = r##"{"username":"a","password":"p"#w"}"##; +=== FN12a findings EmbeddedJson:password:1 +let body = "{\"username\":\"a\",\"password\":\"pw-literal\"}"; +=== FN12b missed double-escaped JSON +let body = "{\\\"password\\\":\\\"pw\\\"}"; +=== FN12c findings EmbeddedJson:password:1 +let body = "{ \"password\" : \"pw-literal\" }"; +=== FN13a findings Binding:password:1 +let password = b"pw-literal-1234"; +=== FN13b findings EmbeddedJson:password:1 +let body = Body::from(&b"{\"password\":\"pw\"}"[..]); +=== FN13c findings Call:hash_password:1 +hash_password(b"pw-literal"); +=== FN14a missed non-English or fullwidth names +let contraseña = "pw-literal-1234"; +=== FN14b findings Binding:password:1 +let password = "pässwörd-литерал"; +=== FN14c missed non-English or fullwidth names +let body = json!({ "password": "x" }); +=== FN15 missed username variable beside a literal password +let username = "avery.admin"; +login(app, username, "pw-literal-1234").await; +=== FN16 missed brace-led JSON value +let body = r#"{"password":"{not-a-format}"}"#; +=== FN17 missed index assignment +body["password"] = "pw-literal-1234".into(); +=== FN18 findings KeyPair:password:1 +map.insert("password", "pw-literal-1234"); +=== FN19 missed reassignment +let mut password = String::new(); +password = "pw-literal-1234".to_owned(); +=== FN20 missed non-sensitive names +const ADMIN_PW: &str = "pw-literal-1234"; +=== FN21 missed non-sensitive names +let pwd = "pw-literal-1234"; let pass = "x"; let creds = "x"; let token = "x"; +=== FN22 findings Binding:password:1 +let password = format!("pw-{}", 1); +=== FN23 missed constructors and other helpers +LoginRequest::new("avery.admin", "pw-literal-1234") +=== FN24 findings Binding:PASSWORD:1 +#[cfg(test)] mod tests { const PASSWORD: &str = "x"; } +=== FN26 findings EmbeddedJson:password:1 +format!(r#"{{"password":"pw-{}"}}"#, 1) +=== FN27 findings Binding:PASSWORDS:1 +static PASSWORDS: &[&str] = &["a", "b"]; +=== FN28 findings Binding:password:1 +let password: &'static str = "pw"; +=== FN29 findings Binding:password:1 +let password = c"pw-literal"; +=== FN30 findings Field:password:1 +Login { password: &"pw" } +=== FN31 findings Binding:password:1 +let password = || "pw-literal"; +=== FN10 findings Binding:password:1 +let password = include_str!("fixtures/password.txt"); +=== FN10b missed included file contents +let cookie = hash_password(include_str!("fixtures/creds.txt")); +=== FN25a findings Call:signIn:1 +signIn(app, "a", "b"); +=== FN25b missed constructors and other helpers +SignIn::new("a","b"); +=== FN25c findings Call:log_in:1 +log_in(app, "a", "b"); +=== FN25d findings Call:authenticate:1 +authenticate(app, "a", "b"); +=== FP01 findings +let username = "avery.admin"; login(app, username, support::password()).await; +=== FP02 findings +sqlx::query("SELECT password_hash FROM user WHERE username = ?").bind("avery.admin"); +=== FP03 findings Binding:PASSWORD_HASH_COLUMN:1 +const PASSWORD_HASH_COLUMN: &str = "password_hash"; +=== FP04 findings Binding:password_error:1 +let password_error = "password must be at least 12 bytes"; +=== FP05 findings +assert_eq!(body["error"], "password_too_short"); +=== FP06 findings +let body = json!({ "username": "a", "password": null }); +=== FP07 findings +let body = json!({ "username": "a", "password": "" }); +=== FP08 findings JsonKey:password:1 +let body = json!({ "password": "" }); +=== FP09 findings +assert!(html.contains("Password")); +=== FP10 findings +Field { label: "Password", name: "password" } +=== FP11 findings +/// The `password: "x"` shape is forbidden; `let password = "x"` too. +fn f() {} +=== FP12 findings +assert!(!body.contains("\"password\":\"")); +=== FP13 findings +assert!(!export.contains(r#""password":""#)); +=== FP14 findings +let body = json!({ "must_change_password": true }); +=== FP15 findings JsonKey:password_policy:1 +let body = json!({ "password_policy": "length" }); +=== FP16 findings Binding:secret_kind:1 +let secret_kind = "reset_code"; +=== FP17 findings Call:get_login_page:1 +let response = get_login_page(&addr, "/login", "text/html").await; +=== FP18 findings Call:initialize:1 +let x = config.initialize("agency-a", "America/Chicago"); +=== FP19 findings +let s = secrets::SecretString::from(code); +=== FP20 findings +assert_eq!(err.to_string(), "password: too short"); +=== FP21 findings Field:password_message:1 +Expected { password_message: "Enter your password" } +=== FP22 findings EmbeddedJson:password:1 +assert!(log.contains("\"password\":\"[REDACTED]\"")); +=== FP23 findings Binding:secrets_dir:1 +let secrets_dir = "secrets"; +=== FP24 findings Call:post_sign_in:1 +let response = post_sign_in(&addr, "/api/auth/login", "{}").await; +=== FP25 findings +match key { "password" => {}, _ => {} } +=== FP26 findings EmbeddedJson:password:1 +#[doc = "{\"password\":\"x\"}"] +=== FP27 findings +let body = format!("{{\"password\":\"{}\"}}", support::password()); +=== closed-response findings Binding:wrong_password:1 +let wrong_password = post_json(app, "/api/auth/login", None, body).await; +=== allowed-01 findings +const PASSWORD_MIN: usize = 12; +=== allowed-02 findings +let password = support::password(); +=== allowed-03 findings +static PASSWORD: OnceLock = OnceLock::new(); +=== allowed-04 findings +let wrong = support::wrong_password(); +=== allowed-05 findings +let fresh = support::fresh_password(); +=== allowed-06 findings +fn login(&self, username: &str, password: &str) -> String { todo!() } +=== allowed-07 findings +fx.login("avery.admin", support::password()).await; +=== allowed-08 findings +fx.login("casey.api").await; +=== allowed-09 findings +live_login(&addr, "avery.admin").await; +=== allowed-10 findings +secrets::hash_password(support::password()).expect("hash"); +=== allowed-11 findings +let secret = text.into_iter().collect(); +=== allowed-12 findings +sqlx::query("SELECT password_hash FROM user"); +=== allowed-13 findings +("user.password_hash", "credential") +=== allowed-14 findings +("password_reset_code", "credential") +=== allowed-15 findings +format!(r#"{{"username":"{u}","password":"{}"}}"#, support::password()) +=== allowed-16 findings +// const PASSWORD: &str = "in a comment"; +=== allowed-17 findings +/* let password = "in a block comment"; */ +=== allowed-18 findings +json!({ "password": support::password() }) +=== allowed-19 findings +json!({"username": "jordan.trainee", "password": ""}) +=== allowed-20 findings +let password = ""; +=== allowed-21 findings +Login { password: "" } +=== allowed-22 findings +form(&[("password", "")]) +=== allowed-23 findings +assert!(!body.contains("\"password\":\"")); +=== allowed-24 findings +let near_miss = format!("{prefix}{}", last); +=== allowed-25 findings +const WORDS: [&str; 3] = ["password", "passphrase", "secret"]; +=== allowed-26 findings +assert_eq!(field, "password", "the form names its field"); +=== R01 findings Binding:password:4 +// a +// b +let x = 1; +let password = "pw"; +=== R02 findings Binding:password:2 +/* outer /* inner " */ still comment " */ +let password = "pw"; +=== R03 findings Binding:password:2 +/* /* */ let password = "hidden"; */ +let password = "pw"; +=== R04 findings Binding:password:2 +fn f<'a>(x: &'a str) -> &'a str { x } +let password = "pw"; +=== R05 findings Binding:password:5 +let q = '"'; +let e = '\''; +let b = b'"'; +let u = '\u{22}'; +let password = "pw"; +=== R06 findings Binding:password:2 +let s = r###"a"##"b"#"###; +let password = "pw"; +=== R07 findings Binding:password:2 +let s = "\\"; +let password = "pw"; +=== R08 findings Binding:password:2 +let url = "http://x/*y"; +let password = "pw"; +=== R09 findings Binding:password:3 +/** let password = "doc"; */ +//! let password = "inner doc"; +let password = "pw"; +=== R10 findings Binding:password:1 +let password = r#"pw +=== R11 robust +let s = "abc +let password = "pw"; +=== R12 findings Binding:password:2 +assert_eq!(c, '\\'); +let password = "pw"; +=== R13 findings Binding:password:2 +'outer: loop { break 'outer; } +let password = "pw"; +=== R14 findings Binding:password:1 +let r#password = "pw"; +=== R15 findings Binding:password:2 +let s = "é中😀"; +let password = "pw"; +=== R16 robust +let body = r#"a "# "; +let password = "pw"; +=== R17 findings Binding:password:3 +struct S<'a>(&'a str); +impl<'a> S<'a> { fn f(&self) -> char { 'a' } } +let password = "pw"; +=== R18 robust +let s = "abc\ +=== R19 robust +' +=== R20 robust +// a let password = "pw"; diff --git a/crates/consolebook-server/tests/notices.rs b/crates/consolebook-server/tests/notices.rs index 7cd60e0..978877f 100644 --- a/crates/consolebook-server/tests/notices.rs +++ b/crates/consolebook-server/tests/notices.rs @@ -13,7 +13,7 @@ use consolebook_server::data_dir::DataDir; use consolebook_server::notices::{self, NoticeKind}; use consolebook_server::{http, secrets, setup, storage, users}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -41,12 +41,12 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") .expect("accepted"); - let hash = secrets::hash_password(PASSWORD).expect("hash"); + let hash = secrets::hash_password(support::password()).expect("hash"); let mut conn = pool.acquire().await.expect("conn"); let trainee_id = users::create(&mut conn, "taylor.trainee", "Taylor Trainee", "", "", &hash) @@ -66,30 +66,7 @@ impl Fixture { } async fn login(&self, username: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let cookie = response - .headers() - .get(axum::http::header::SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, support::password()).await } } diff --git a/crates/consolebook-server/tests/programs.rs b/crates/consolebook-server/tests/programs.rs index 6f15dab..e8fd403 100644 --- a/crates/consolebook-server/tests/programs.rs +++ b/crates/consolebook-server/tests/programs.rs @@ -11,7 +11,7 @@ use consolebook_server::programs::{ }; use consolebook_server::{setup, storage, users}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -36,7 +36,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") diff --git a/crates/consolebook-server/tests/programs_api.rs b/crates/consolebook-server/tests/programs_api.rs index 40b9643..8635f81 100644 --- a/crates/consolebook-server/tests/programs_api.rs +++ b/crates/consolebook-server/tests/programs_api.rs @@ -3,14 +3,14 @@ //! the wire. Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::data_dir::DataDir; use consolebook_server::{http, secrets, setup, storage, users}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -34,7 +34,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -48,7 +48,7 @@ impl Fixture { /// Creates a user with no capability grants who can sign in. async fn create_plain_user(&self) { - let hash = secrets::hash_password(PASSWORD).expect("hash"); + let hash = secrets::hash_password(support::password()).expect("hash"); let mut conn = self.pool.acquire().await.expect("conn"); users::create(&mut conn, "jordan.trainer", "Jordan Trainer", "", "", &hash) .await @@ -56,30 +56,7 @@ impl Fixture { } async fn login(&self, username: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, support::password()).await } } diff --git a/crates/consolebook-server/tests/public_url.rs b/crates/consolebook-server/tests/public_url.rs index 6483fa8..765339e 100644 --- a/crates/consolebook-server/tests/public_url.rs +++ b/crates/consolebook-server/tests/public_url.rs @@ -10,7 +10,7 @@ use tower::ServiceExt; use consolebook_server::public_url::PublicUrl; use consolebook_server::{http, setup, storage}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; async fn initialized_pool() -> (tempfile::TempDir, sqlx::SqlitePool) { let tmp = tempfile::tempdir().expect("temp dir"); @@ -28,7 +28,7 @@ async fn initialized_pool() -> (tempfile::TempDir, sqlx::SqlitePool) { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -47,7 +47,7 @@ async fn cookie_headers(state: http::AppState) -> (String, String) { .uri("/api/auth/login") .header(CONTENT_TYPE, "application/json") .body(Body::from( - serde_json::json!({ "username": "avery.admin", "password": PASSWORD }) + serde_json::json!({ "username": "avery.admin", "password": support::password() }) .to_string(), )) .expect("request"), diff --git a/crates/consolebook-server/tests/record_export.rs b/crates/consolebook-server/tests/record_export.rs index 087b60e..6ed2bba 100644 --- a/crates/consolebook-server/tests/record_export.rs +++ b/crates/consolebook-server/tests/record_export.rs @@ -8,7 +8,7 @@ use std::io::{Cursor, Read, Write}; use axum::body::Body; -use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}; use axum::http::{HeaderMap, Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::draft_content::{self, DraftContent, NarrativeEntry, RatingEntry}; @@ -24,13 +24,13 @@ use consolebook_server::record_export::{ use consolebook_server::training_sessions::{self, Disposition, SessionInput}; use consolebook_server::{ amendments, assignments, canonical, data_dir::DataDir, enrollments, evaluation_drafts, - finalization, setup, storage, users, + finalization, setup, storage, }; use http_body_util::BodyExt; use tower::ServiceExt; use zip::write::{SimpleFileOptions, ZipWriter}; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; /// 2026-09-01T19:00:00Z, the instant every deterministic export below /// is stamped with. @@ -65,7 +65,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -82,34 +82,11 @@ impl Fixture { } async fn login(&self, username: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, support::password()).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -119,15 +96,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } /// The stored row of one version: bytes and both hashes. diff --git a/crates/consolebook-server/tests/signoff_history.rs b/crates/consolebook-server/tests/signoff_history.rs index 54aae7f..e2a0619 100644 --- a/crates/consolebook-server/tests/signoff_history.rs +++ b/crates/consolebook-server/tests/signoff_history.rs @@ -6,7 +6,7 @@ //! shape. Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::{Capability, RoleBundle}; use consolebook_server::lifecycle::EnrollmentEventKind; @@ -16,12 +16,12 @@ use consolebook_server::programs::{ }; use consolebook_server::task_signoffs::{self, SignoffHistory, SignoffKind, SignoffRefusal}; use consolebook_server::{ - assignments, data_dir::DataDir, enrollments, lifecycle, setup, storage, trainee_packet, users, + assignments, data_dir::DataDir, enrollments, lifecycle, setup, storage, trainee_packet, }; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; const OPEN_POLICY: PolicyDef = PolicyDef { review_approved: false, @@ -52,7 +52,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -69,34 +69,11 @@ impl Fixture { } async fn login(&self, username: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, support::password()).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -106,15 +83,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } } diff --git a/crates/consolebook-server/tests/summaries_signoffs.rs b/crates/consolebook-server/tests/summaries_signoffs.rs index 0229f53..fa301bf 100644 --- a/crates/consolebook-server/tests/summaries_signoffs.rs +++ b/crates/consolebook-server/tests/summaries_signoffs.rs @@ -6,7 +6,7 @@ //! fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::acknowledgments::{self, TraineeAckKind}; use consolebook_server::amendments; @@ -20,13 +20,11 @@ use consolebook_server::programs::{ use consolebook_server::summaries; use consolebook_server::task_signoffs::{self, SignoffKind, SignoffRefusal}; use consolebook_server::training_sessions::{self, Disposition, SessionInput}; -use consolebook_server::{ - assignments, canonical, data_dir::DataDir, enrollments, setup, storage, users, -}; +use consolebook_server::{assignments, canonical, data_dir::DataDir, enrollments, setup, storage}; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; const OPEN_POLICY: PolicyDef = PolicyDef { review_approved: false, @@ -57,7 +55,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -74,34 +72,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -111,15 +86,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn version_row(&self, record_id: i64, number: i64) -> (i64, Vec, String, String) { @@ -773,7 +739,7 @@ async fn summary_and_signoff_api_round_trip() { .expect("call") .expect("daily sealed"); let (daily_v1, _, _, _) = fx.version_row(s.record_id, 1).await; - let casey = fx.login("casey.api", PASSWORD).await; + let casey = fx.login("casey.api", support::password()).await; let tasks = task_ids(&fx, s.version_id).await; let (status, body) = request( diff --git a/crates/consolebook-server/tests/support/mod.rs b/crates/consolebook-server/tests/support/mod.rs new file mode 100644 index 0000000..f5f3075 --- /dev/null +++ b/crates/consolebook-server/tests/support/mod.rs @@ -0,0 +1,125 @@ +//! Shared support for the integration tests: the one owner of test +//! credentials (#126). +//! +//! Every password a test uses comes from here and is generated at run +//! time, once per test process, from OS randomness, except the +//! allowlisted compatibility fixture in `upgrade_compat.rs`. Test files +//! declare no password literal of their own, and +//! `tests/no_credential_literals.rs` fails `cargo test` on the shapes it +//! names in its scan contract. +//! +//! The sign-in helper never formats a username or password into a +//! panic or assertion message; it names the request path instead. +//! +//! Include it from a test crate root with `mod support;`. Each crate +//! uses only some of these items. +#![allow(dead_code)] + +use std::sync::OnceLock; + +use axum::body::Body; +use axum::http::header::{CONTENT_TYPE, SET_COOKIE}; +use axum::http::{Request, StatusCode}; +use consolebook_server::capabilities::RoleBundle; +use consolebook_server::secrets::{MAX_PASSWORD_BYTES, MIN_PASSWORD_BYTES}; +use consolebook_server::users; +use tower::ServiceExt; + +const LOGIN_PATH: &str = "/api/auth/login"; + +/// A new password of `invented-` and 32 random hex digits: 41 bytes, +/// inside the product's length rules, and never equal to a username. +fn generate() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS randomness for a test password"); + let generated = format!("invented-{:032x}", u128::from_le_bytes(bytes)); + assert!((MIN_PASSWORD_BYTES..=MAX_PASSWORD_BYTES).contains(&generated.len())); + generated +} + +/// The password every invented account in this test process signs in +/// with. +pub fn password() -> &'static str { + static PASSWORD: OnceLock = OnceLock::new(); + PASSWORD.get_or_init(generate) +} + +/// A well-formed password that no account in this test process has. +pub fn wrong_password() -> &'static str { + static WRONG: OnceLock = OnceLock::new(); + let wrong = WRONG.get_or_init(generate); + // `assert!`, not `assert_ne!`: a failure never prints a password. + assert!(wrong != password(), "the wrong password differs"); + wrong +} + +/// A new well-formed password on every call, for flows that set or +/// rotate one (reset, recovery, first sign-in). +pub fn fresh_password() -> String { + let fresh = generate(); + assert!(fresh != password(), "a fresh password differs"); + fresh +} + +/// One byte shorter than the product's minimum, so the length rule +/// alone refuses it. +pub fn too_short_password() -> String { + password()[..MIN_PASSWORD_BYTES - 1].to_owned() +} + +/// Signs in over `app` and returns the session cookie's value. +pub async fn login(app: axum::Router, username: &str, password: &str) -> String { + let body = serde_json::json!({ "username": username, "password": password }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(LOGIN_PATH) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK, "POST {LOGIN_PATH}"); + let cookie = response + .headers() + .get(SET_COOKIE) + .expect("session cookie") + .to_str() + .expect("ascii"); + let (pair, _) = cookie.split_once(';').expect("cookie attributes"); + pair.split_once('=').expect("cookie pair").1.to_string() +} + +/// Creates an account through the administrator's reset-code flow and +/// sets [`password`] through the standard reset; returns its id. +pub async fn user_with_role( + pool: &sqlx::SqlitePool, + admin_id: i64, + username: &str, + display_name: &str, + employee_id: &str, + title: &str, + role: RoleBundle, +) -> i64 { + let created = users::create_with_reset_code( + pool, + admin_id, + username, + display_name, + employee_id, + title, + role, + ) + .await + .expect("create") + .expect("accepted"); + assert_eq!( + users::use_reset_code(pool, username, &created.reset_code.raw, password()) + .await + .expect("reset"), + users::ResetOutcome::Done + ); + created.id +} diff --git a/crates/consolebook-server/tests/trainee_packet.rs b/crates/consolebook-server/tests/trainee_packet.rs index f278ed5..f743dee 100644 --- a/crates/consolebook-server/tests/trainee_packet.rs +++ b/crates/consolebook-server/tests/trainee_packet.rs @@ -10,7 +10,7 @@ use std::io::{Cursor, Read, Write}; use std::time::Duration; use axum::body::Body; -use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}; use axum::http::{HeaderMap, Request, StatusCode}; use consolebook_server::acknowledgments::{self, AckKind, TraineeAckKind}; use consolebook_server::capabilities::RoleBundle; @@ -30,7 +30,7 @@ use consolebook_server::trainee_packet::{ use consolebook_server::training_sessions::{self, Disposition, SessionInput}; use consolebook_server::{ amendments, assignments, canonical, data_dir::DataDir, enrollments, evaluation_drafts, - finalization, setup, storage, users, + finalization, setup, storage, }; use http_body_util::BodyExt; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; @@ -40,7 +40,7 @@ use zip::write::{SimpleFileOptions, ZipWriter}; #[path = "trainee_packet/pin_history.rs"] mod pin_history; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; /// 2026-09-01T19:00:00Z. const EXPORTED_AT: i64 = 1_788_289_200; @@ -74,7 +74,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -91,34 +91,11 @@ impl Fixture { } async fn login(&self, username: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": PASSWORD }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, support::password()).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -128,15 +105,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn audit_count(&self, kind: &str) -> i64 { diff --git a/crates/consolebook-server/tests/training_sessions.rs b/crates/consolebook-server/tests/training_sessions.rs index 54da63c..515f958 100644 --- a/crates/consolebook-server/tests/training_sessions.rs +++ b/crates/consolebook-server/tests/training_sessions.rs @@ -4,7 +4,7 @@ //! capability-plus-scope gates. Every fixture is invented. use axum::body::Body; -use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE}; +use axum::http::header::{CONTENT_TYPE, COOKIE}; use axum::http::{Request, StatusCode}; use consolebook_server::capabilities::RoleBundle; use consolebook_server::lifecycle::{self, EnrollmentEventKind}; @@ -16,12 +16,12 @@ use consolebook_server::training_sessions::{ self, Disposition, SessionInput, SessionRefusal, SessionUpdate, }; use consolebook_server::{ - assignments, data_dir::DataDir, enrollments, session_membership, setup, storage, users, + assignments, data_dir::DataDir, enrollments, session_membership, setup, storage, }; use http_body_util::BodyExt; use tower::ServiceExt; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; struct Fixture { _tmp: tempfile::TempDir, @@ -46,7 +46,7 @@ impl Fixture { "Example County Communications", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("initialize") @@ -63,34 +63,11 @@ impl Fixture { } async fn login(&self, username: &str, password: &str) -> String { - let response = self - .app() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ "username": username, "password": password }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK, "login {username}"); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("cookie") - .to_str() - .expect("ascii"); - let (pair, _) = cookie.split_once(';').expect("attrs"); - pair.split_once('=').expect("pair").1.to_string() + support::login(self.app(), username, password).await } async fn user_with_role(&self, username: &str, display_name: &str, role: RoleBundle) -> i64 { - let created = users::create_with_reset_code( + support::user_with_role( &self.pool, self.admin_id, username, @@ -100,15 +77,6 @@ impl Fixture { role, ) .await - .expect("create") - .expect("accepted"); - assert_eq!( - users::use_reset_code(&self.pool, username, &created.reset_code.raw, PASSWORD) - .await - .expect("reset"), - users::ResetOutcome::Done - ); - created.id } async fn publish(&self, program_id: Option, content: &VersionContent) -> (i64, i64) { @@ -578,9 +546,9 @@ async fn session_gates_membership_and_close_rules() { // Reads: a member reads the session without any assignment; the // enrollment history stays closed to them. - let jordan = fx.login("jordan.trainer", PASSWORD).await; - let rowan = fx.login("rowan.trainer", PASSWORD).await; - let taylor = fx.login("taylor.trainee", PASSWORD).await; + let jordan = fx.login("jordan.trainer", support::password()).await; + let rowan = fx.login("rowan.trainer", support::password()).await; + let taylor = fx.login("taylor.trainee", support::password()).await; let (status, _) = request( fx.app(), "GET", @@ -869,7 +837,7 @@ async fn sessions_api_round_trip() { .await .expect("call") .expect("enrolled"); - let admin = fx.login("avery.admin", PASSWORD).await; + let admin = fx.login("avery.admin", support::password()).await; let (status, body) = request( fx.app(), diff --git a/crates/consolebook-server/tests/upgrade_compat.rs b/crates/consolebook-server/tests/upgrade_compat.rs index 56c8db0..25cb069 100644 --- a/crates/consolebook-server/tests/upgrade_compat.rs +++ b/crates/consolebook-server/tests/upgrade_compat.rs @@ -10,6 +10,8 @@ use consolebook_server::secrets; use consolebook_server::storage::{self, MIGRATOR}; +mod support; + /// The rows the released v0.2.0 binary wrote; see the file's header. const RELEASED_MIGRATIONS: &str = include_str!("fixtures/migrations-v0.2.0.txt"); @@ -128,12 +130,15 @@ async fn a_database_holding_the_released_rows_opens_and_a_modified_one_does_not( } /// A password hashed before the upgrade still signs in; a wrong one -/// still fails. +/// still fails, even when only its final character differs. #[test] fn a_password_hashed_by_the_earlier_build_still_verifies() { assert!(secrets::verify_password(PHC_PASSWORD, PHC_FROM_ARGON2_0_5)); + let (prefix, last) = PHC_PASSWORD.split_at(PHC_PASSWORD.len() - 1); + let near_miss = format!("{prefix}{}", if last == "7" { '8' } else { '7' }); + assert!(!secrets::verify_password(&near_miss, PHC_FROM_ARGON2_0_5)); assert!(!secrets::verify_password( - "invented-compat-passphrase-137", + support::wrong_password(), PHC_FROM_ARGON2_0_5 )); } diff --git a/crates/consolebook-server/tests/web_shell.rs b/crates/consolebook-server/tests/web_shell.rs index bab848f..8866451 100644 --- a/crates/consolebook-server/tests/web_shell.rs +++ b/crates/consolebook-server/tests/web_shell.rs @@ -14,6 +14,8 @@ use tower::ServiceExt; use consolebook_server::data_dir::DataDir; use consolebook_server::{http, setup, storage, web_assets}; +mod support; + async fn app() -> (tempfile::TempDir, axum::Router, sqlx::SqlitePool) { let tmp = tempfile::tempdir().expect("create temp dir"); let data_dir = DataDir::new(tmp.path().join("data")); @@ -100,7 +102,7 @@ async fn instance_reports_initialization_state() { "Example County Communications", "avery.admin", "Avery Admin", - "invented-passphrase-1", + support::password(), ) .await .expect("initialize") diff --git a/crates/consolebook-server/tests/write_transactions.rs b/crates/consolebook-server/tests/write_transactions.rs index 6d4fdd0..bd6bb84 100644 --- a/crates/consolebook-server/tests/write_transactions.rs +++ b/crates/consolebook-server/tests/write_transactions.rs @@ -13,11 +13,12 @@ mod program_writes; #[path = "write_transactions/training.rs"] mod training_writes; +mod support; + const ACTOR: i64 = 1; const TRAINEE: i64 = 2; const TRAINER: i64 = 3; const OTHER_TRAINER: i64 = 4; -const PASSWORD: &str = "invented-passphrase-1"; struct Fixture { tmp: tempfile::TempDir, diff --git a/crates/consolebook-server/tests/write_transactions/accounts.rs b/crates/consolebook-server/tests/write_transactions/accounts.rs index 9ac8a65..83e1878 100644 --- a/crates/consolebook-server/tests/write_transactions/accounts.rs +++ b/crates/consolebook-server/tests/write_transactions/accounts.rs @@ -90,8 +90,18 @@ async fn reset_code_is_consumed_once_and_revokes_sessions() { .0; let (left, right) = contend( &fx.pool, - users::use_reset_code(&fx.pool, "taylor.trainee", &issued.code.raw, PASSWORD), - users::use_reset_code(&fx.pool, "taylor.trainee", &issued.code.raw, PASSWORD), + users::use_reset_code( + &fx.pool, + "taylor.trainee", + &issued.code.raw, + support::password(), + ), + users::use_reset_code( + &fx.pool, + "taylor.trainee", + &issued.code.raw, + support::password(), + ), ) .await; let outcomes = (left.expect("left"), right.expect("right")); @@ -135,7 +145,7 @@ async fn setup_has_one_administrator_and_consumes_its_code_once() { "Invented County", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ), setup::initialize( &fx.pool, @@ -143,7 +153,7 @@ async fn setup_has_one_administrator_and_consumes_its_code_once() { "Invented County", "rowan.admin", "Rowan Admin", - PASSWORD, + support::password(), ), ) .await, @@ -179,7 +189,7 @@ async fn setup_code_rotation_serializes_with_initialization() { "Invented County", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ), setup::issue_setup_code(&fx.pool), ) @@ -193,7 +203,7 @@ async fn setup_code_rotation_serializes_with_initialization() { "Invented County", "avery.admin", "Avery Admin", - PASSWORD, + support::password(), ) .await .expect("call") diff --git a/docs/development.md b/docs/development.md index 4c7cb65..36185e6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,7 +36,11 @@ tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. `lib.rs` exposes the library modules. Integration tests in `crates/consolebook-server/tests/` are named by capability; migration files in `crates/consolebook-server/migrations/` own schema, constraints, and triggers. -Read both when changing a persisted contract. +Read both when changing a persisted contract. Test credentials come from +`tests/support/` (`mod support;`), which generates them at run time, except +the allowlisted compatibility fixture in `tests/upgrade_compat.rs`; +`tests/no_credential_literals.rs` fails `cargo test` on the shapes its scan +contract names. `scripts/` holds repository guards that the gates run before the build; `CONTRIBUTING.md` lists each one in the local command sequence.