From fc3f7fe2b388d65d140e208ba265c4535d527259 Mon Sep 17 00:00:00 2001 From: Peter Permenter <41281403+TusanHomichi@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:33:01 -0700 Subject: [PATCH 1/2] test: one runtime test credential, and a guard against password literals Integration tests no longer declare passwords: tests/support provides a per-process password generated from OS randomness (plus wrong, fresh, and too-short variants), the shared login and user helpers that 14 and 12 test crates had each copied, and assertion messages that name no user. With no literal there is nothing for CodeQL's hard-coded-credential and cleartext-logging rules to flag in new test files (#126; recurred on #130 and #139). tests/no_credential_literals.rs scans every test source and fails on a password, passphrase, or secret literal in a binding, struct field, JSON key, JSON-in-a-string, or a credential call, with its scan contract in the header, an exact-path allowlist (upgrade_compat's argon2 0.5 fixture), and negative controls for every shape it flags and every allowed shape it must not; against the pre-change tests it reports 38 findings across 28 files. Closes #126 Co-Authored-By: Claude Opus 5.5 --- .../tests/acknowledgments.rs | 48 +- crates/consolebook-server/tests/amendments.rs | 50 +- crates/consolebook-server/tests/auth_flows.rs | 54 +- .../consolebook-server/tests/backup_copy.rs | 35 +- .../tests/backup_restore.rs | 4 +- .../consolebook-server/tests/draft_review.rs | 50 +- .../consolebook-server/tests/enrollments.rs | 52 +- .../tests/evaluation_drafts.rs | 50 +- .../tests/example_program.rs | 4 +- .../tests/export_stream_bytes.rs | 10 +- .../consolebook-server/tests/finalization.rs | 50 +- .../tests/getting_started.rs | 36 +- .../tests/installation_export.rs | 42 +- crates/consolebook-server/tests/lifecycle.rs | 58 +- .../tests/no_credential_literals.rs | 742 ++++++++++++++++++ crates/consolebook-server/tests/notices.rs | 31 +- crates/consolebook-server/tests/programs.rs | 4 +- .../consolebook-server/tests/programs_api.rs | 33 +- crates/consolebook-server/tests/public_url.rs | 6 +- .../consolebook-server/tests/record_export.rs | 44 +- .../tests/signoff_history.rs | 44 +- .../tests/summaries_signoffs.rs | 48 +- .../consolebook-server/tests/support/mod.rs | 124 +++ .../tests/trainee_packet.rs | 44 +- .../tests/training_sessions.rs | 52 +- .../tests/upgrade_compat.rs | 4 +- crates/consolebook-server/tests/web_shell.rs | 4 +- .../tests/write_transactions.rs | 2 +- .../tests/write_transactions/accounts.rs | 22 +- docs/development.md | 5 +- 30 files changed, 1064 insertions(+), 688 deletions(-) create mode 100644 crates/consolebook-server/tests/no_credential_literals.rs create mode 100644 crates/consolebook-server/tests/support/mod.rs 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..17fc309 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; @@ -259,14 +259,14 @@ async fn login_rejects_bad_password_and_unknown_user_identically() { 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); @@ -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..88ba979 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(), @@ -295,7 +272,7 @@ async fn a_created_user_signs_in_through_the_reset_flow() { "POST", "/api/auth/login", None, - Some(serde_json::json!({"username": "jordan.trainee", "password": ""})), + Some(serde_json::json!({"username": "jordan.trainee", "password": String::new()})), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -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..beab07f 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; - +mod support; use consolebook_server::export_stream::EntryBuffer; +use std::io::Write; 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..8b915d9 --- /dev/null +++ b/crates/consolebook-server/tests/no_credential_literals.rs @@ -0,0 +1,742 @@ +//! 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. +//! +//! **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. A *literal expression* is a 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 JSON 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 is a literal expression. +//! 2. *Field*: `sensitive_name: literal` (a struct-literal field). +//! 3. *JSON key*: a sensitive key literal followed by `:` and a literal +//! expression — the `json!` shape, matched wherever it appears. +//! 4. *Embedded JSON*: a literal whose text holds a sensitive JSON key +//! with a string value (`"password":"…"`, escaped or raw). A value that +//! begins with `{` is a format interpolation, not a credential. +//! 5. *Call*: a literal expression passed +//! - as any argument to a function or method whose 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 to a function or method whose name contains +//! `login` or `sign_in`, when 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. +//! +//! **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.** A credential reaching a sink only through a +//! non-sensitive name (`let x = "…"; hash_password(x)`); a literal at a +//! non-final position of a sign-in or reset helper, or a sign-in helper's +//! literal password beside a username variable; a credential built +//! by `format!`, `concat!`, or arithmetic; literals in non-Rust fixtures +//! (`tests/fixtures/`); and anything outside `tests/`. Review covers +//! those; this guard stops the shape that recurred. +//! +//! 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; 2] = ["login", "sign_in"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Shape { + Binding, + Field, + JsonKey, + EmbeddedJson, + Call, +} + +#[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 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 the literal expression starting at `at`, if one does. +fn literal_expression(tokens: &[Token], mut at: usize) -> Option { + while punct(tokens, at, '&') { + at += 1; + } + if let Some(Token { + tok: Tok::Str(_), + line, + }) = tokens.get(at) + { + return Some(*line); + } + 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); + } + 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, ')')); + starts +} + +/// The text of a literal holds a sensitive JSON key with a string value. +/// 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.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_expression(&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 punct(&tokens, at + 1, '(') => { + if at > 0 && ident(&tokens, at - 1) == Some("fn") { + continue; + } + let args = arguments(&tokens, at + 1); + 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.as_str()) + || (SIGN_IN_FRAGMENTS.iter().any(|f| callee.contains(f)) && literals >= 2)); + let checked: &[usize] = if sensitive(callee) { + &args + } else if last_is_password { + &args[args.len() - 1..] + } else { + &[] + }; + for start in checked { + 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 let Some(key) = embedded_json_key(text) { + flag(token.line, Shape::EmbeddedJson, &key); + } + } + _ => {} + } + } + findings +} + +fn rust_files(dir: &Path, out: &mut Vec) { + let mut entries: Vec = std::fs::read_dir(dir) + .expect("read test directory") + .map(|entry| entry.expect("directory entry").path()) + .collect(); + entries.sort(); + for path in entries { + if path.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).expect("read test source"); + 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") +} + +// ----------------------------------------------------------------- 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) + ); +} + +/// One synthetic source per forbidden shape, with the finding it must +/// produce. +fn forbidden_cases() -> Vec<(String, Shape, &'static str)> { + let key = |key: &str, value: &str| format!("json!({{ \"{key}\": {value} }})"); + vec![ + ( + r#"const PASSWORD: &'static str = "x";"#.into(), + Shape::Binding, + "PASSWORD", + ), + ( + r#"static ADMIN_SECRET: &'static str = "x";"#.into(), + Shape::Binding, + "ADMIN_SECRET", + ), + ( + r#"let new_password = "x";"#.into(), + Shape::Binding, + "new_password", + ), + ( + r#"let mut passphrase: String = "x".to_owned();"#.into(), + Shape::Binding, + "passphrase", + ), + ( + r#"let password = String::from("x");"#.into(), + Shape::Binding, + "password", + ), + ( + r##"const PASSWORD: &str = r#"x"#;"##.into(), + Shape::Binding, + "PASSWORD", + ), + (r#"let password = r"x";"#.into(), Shape::Binding, "password"), + // A quote in a character literal opens no string. + ( + r#"let q = '"'; let e = '\''; let password = "x";"#.into(), + Shape::Binding, + "password", + ), + ( + r#"Login { password: "x" }"#.into(), + Shape::Field, + "password", + ), + (key("password", r#""x""#), Shape::JsonKey, "password"), + ( + key("new_password", r#""x""#), + Shape::JsonKey, + "new_password", + ), + ( + key("current_password", r##"r#"x"#"##), + Shape::JsonKey, + "current_password", + ), + ( + format!("let body = {:?};", key("password", r#""x""#)), + Shape::EmbeddedJson, + "password", + ), + ( + r#"secrets::hash_password("x")"#.into(), + Shape::Call, + "hash_password", + ), + ( + r#"secrets::verify_password("x", &phc)"#.into(), + Shape::Call, + "verify_password", + ), + ( + r#"fx.login("avery.admin", "x")"#.into(), + Shape::Call, + "login", + ), + ( + r#"support::sign_in(app, "avery.admin", &"x".to_owned())"#.into(), + Shape::Call, + "sign_in", + ), + ( + r#"users::use_reset_code(&pool, "a", &code, "x")"#.into(), + Shape::Call, + "use_reset_code", + ), + ( + r#"setup::initialize(&p, &c, "A", "a", "A", r"x",)"#.into(), + Shape::Call, + "initialize", + ), + ] +} + +/// Each forbidden shape is found, naming the file and line. +#[test] +fn each_forbidden_shape_is_found_with_its_file_and_line() { + for (body, shape, subject) in forbidden_cases() { + let src = format!("// line 1\n\n{body}\n"); + let found = scan_source("synthetic.rs", &src); + let expected = Finding { + path: "synthetic.rs".into(), + line: 3, + shape, + subject: subject.into(), + }; + assert!( + found.contains(&expected), + "{body}\nexpected {expected}\nfound:\n{}", + listing(&found) + ); + } +} + +/// The shapes the sweep uses, the allowlisted fixture, and look-alikes +/// that carry no credential all scan clean. +#[test] +fn allowed_shapes_yield_no_finding() { + let clean = [ + "const PASSWORD_MIN: usize = 12;", + "let password = support::password();", + "static PASSWORD: OnceLock = OnceLock::new();", + "let wrong = support::wrong_password();", + "let fresh = support::fresh_password();", + "fn login(&self, username: &str, password: &str) -> String { todo!() }", + "fx.login(\"avery.admin\", support::password()).await;", + "fx.login(\"casey.api\").await;", + "live_login(&addr, \"avery.admin\").await;", + "secrets::hash_password(support::password()).expect(\"hash\");", + "let secret = text.into_iter().collect();", + "sqlx::query(\"SELECT password_hash FROM user\");", + "(\"user.password_hash\", \"credential\")", + "format!(r#\"{{\"username\":\"{u}\",\"password\":\"{}\"}}\"#, support::password())", + "// const PASSWORD: &str = \"in a comment\";", + "/* let password = \"in a block comment\"; */", + "let c = '\"'; let password = support::password();", + ]; + for src in clean { + let found = scan_source("synthetic.rs", src); + assert!(found.is_empty(), "{src}\nfound:\n{}", listing(&found)); + } + let key = |value: &str| format!("json!({{ \"pass{}\": {value} }})", "word"); + let found = scan_source("synthetic.rs", &key("support::password()")); + assert!(found.is_empty(), "{}", listing(&found)); +} + +/// 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 token boundaries the rules rely on: a literal spanning lines +/// reports its first line, and a quote inside a raw string or comment does +/// not open a literal. +#[test] +fn literals_are_located_by_their_first_line() { + let src = + "let a = \"one\ntwo\";\n/* \" */\nlet b = r#\"\"\"#;\nconst PASSWORD: &str =\n \"x\";"; + let found = scan_source("synthetic.rs", src); + assert_eq!( + found, + vec![Finding { + path: "synthetic.rs".into(), + line: 6, + shape: Shape::Binding, + subject: "PASSWORD".into(), + }] + ); +} 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..e0d763c --- /dev/null +++ b/crates/consolebook-server/tests/support/mod.rs @@ -0,0 +1,124 @@ +//! 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. No test file +//! declares a password literal, so there is nothing for a static +//! analyzer to report as a hard-coded credential, and +//! `tests/no_credential_literals.rs` fails the build if one returns. +//! +//! 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..52e0fae 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"); @@ -133,7 +135,7 @@ async fn a_database_holding_the_released_rows_opens_and_a_modified_one_does_not( fn a_password_hashed_by_the_earlier_build_still_verifies() { assert!(secrets::verify_password(PHC_PASSWORD, 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..8db2344 100644 --- a/crates/consolebook-server/tests/write_transactions.rs +++ b/crates/consolebook-server/tests/write_transactions.rs @@ -17,7 +17,7 @@ const ACTOR: i64 = 1; const TRAINEE: i64 = 2; const TRAINER: i64 = 3; const OTHER_TRAINER: i64 = 4; -const PASSWORD: &str = "invented-passphrase-1"; +mod support; 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..cf38a8a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,7 +36,10 @@ 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; +`tests/no_credential_literals.rs` fails `cargo test` when a test file +declares its own. `scripts/` holds repository guards that the gates run before the build; `CONTRIBUTING.md` lists each one in the local command sequence. From c19f940d1019a4908fd6f8f3f0c14e3e6a79fc91 Mon Sep 17 00:00:00 2001 From: Peter Permenter <41281403+TusanHomichi@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:52:55 -0700 Subject: [PATCH 2/2] test: close the credential guard's review gaps Independent review found no blockers. Follow-ups: upgrade_compat again checks a one-character near miss of the fixture password, built at run time (a verifier that ignored the last byte passed the previous version); a sensitive binding is flagged for any non-empty literal in its initializer (arrays, vec!, Some, match/if arms, closures), a ("password", "literal") pair and macro/turbofish credential calls are caught, and sign-in helpers are matched by more names; empty literals are exempt, so absence and empty-password tests read naturally; the header lists every remaining miss with its reason. The controls move to a 149-case corpus checked against the header in both directions, the walk skips symbolic links, and an unreadable file is named. Against the pre-change tests: 38 findings in 26 files. Refs #126 Co-Authored-By: Claude Opus 5.5 --- crates/consolebook-server/tests/auth_flows.rs | 6 +- .../consolebook-server/tests/enrollments.rs | 2 +- .../tests/export_stream_bytes.rs | 2 +- .../tests/no_credential_literals.rs | 581 +++++++++++------- .../tests/no_credential_literals/cases.txt | 345 +++++++++++ .../consolebook-server/tests/support/mod.rs | 9 +- .../tests/upgrade_compat.rs | 5 +- .../tests/write_transactions.rs | 3 +- docs/development.md | 7 +- 9 files changed, 728 insertions(+), 232 deletions(-) create mode 100644 crates/consolebook-server/tests/no_credential_literals/cases.txt diff --git a/crates/consolebook-server/tests/auth_flows.rs b/crates/consolebook-server/tests/auth_flows.rs index 17fc309..2abf628 100644 --- a/crates/consolebook-server/tests/auth_flows.rs +++ b/crates/consolebook-server/tests/auth_flows.rs @@ -255,7 +255,7 @@ 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, @@ -269,9 +269,9 @@ async fn login_rejects_bad_password_and_unknown_user_identically() { 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"); } diff --git a/crates/consolebook-server/tests/enrollments.rs b/crates/consolebook-server/tests/enrollments.rs index 88ba979..593f1bf 100644 --- a/crates/consolebook-server/tests/enrollments.rs +++ b/crates/consolebook-server/tests/enrollments.rs @@ -272,7 +272,7 @@ async fn a_created_user_signs_in_through_the_reset_flow() { "POST", "/api/auth/login", None, - Some(serde_json::json!({"username": "jordan.trainee", "password": String::new()})), + Some(serde_json::json!({"username": "jordan.trainee", "password": ""})), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); diff --git a/crates/consolebook-server/tests/export_stream_bytes.rs b/crates/consolebook-server/tests/export_stream_bytes.rs index beab07f..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. -mod support; use consolebook_server::export_stream::EntryBuffer; use std::io::Write; +mod support; fn options() -> zip::write::SimpleFileOptions { zip::write::SimpleFileOptions::default() diff --git a/crates/consolebook-server/tests/no_credential_literals.rs b/crates/consolebook-server/tests/no_credential_literals.rs index 8b915d9..615e0b2 100644 --- a/crates/consolebook-server/tests/no_credential_literals.rs +++ b/crates/consolebook-server/tests/no_credential_literals.rs @@ -9,40 +9,63 @@ //! # Scan contract //! //! **Scope.** Every `.rs` file under `crates/consolebook-server/tests/`, -//! recursively, including `support/` and this file. +//! 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. A *literal expression* is a 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. +//! 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 JSON key that contains `password`, +//! **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 is a literal expression. -//! 2. *Field*: `sensitive_name: literal` (a struct-literal field). +//! 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. *Embedded JSON*: a literal whose text holds a sensitive JSON key -//! with a string value (`"password":"…"`, escaped or raw). A value that -//! begins with `{` is a format interpolation, not a credential. -//! 5. *Call*: a literal expression passed -//! - as any argument to a function or method whose name is sensitive +//! 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 to a function or method whose name contains -//! `login` or `sign_in`, when 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. +//! - 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: @@ -55,13 +78,38 @@ //! (`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.** A credential reaching a sink only through a -//! non-sensitive name (`let x = "…"; hash_password(x)`); a literal at a -//! non-final position of a sign-in or reset helper, or a sign-in helper's -//! literal password beside a username variable; a credential built -//! by `format!`, `concat!`, or arithmetic; literals in non-Rust fixtures -//! (`tests/fixtures/`); and anything outside `tests/`. Review covers -//! those; this guard stops the shape that recurred. +//! **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. @@ -75,17 +123,39 @@ const SENSITIVE: [&str; 3] = ["password", "passphrase", "secret"]; 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; 2] = ["login", "sign_in"]; +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, @@ -295,6 +365,11 @@ fn sensitive(name: &str) -> bool { 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), @@ -306,18 +381,22 @@ 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; } - if let Some(Token { - tok: Tok::Str(_), - line, - }) = tokens.get(at) - { - return Some(*line); - } let from = ident(tokens, at) == Some("String") && punct(tokens, at + 1, ':') && punct(tokens, at + 2, ':') @@ -326,6 +405,59 @@ fn literal_expression(tokens: &[Token], mut at: usize) -> Option { 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 } @@ -354,13 +486,37 @@ fn arguments(tokens: &[Token], open: usize) -> Vec { at += 1; } // A trailing comma leaves an empty last argument. - starts.retain(|start| !punct(tokens, *start, ')')); + starts.retain(|start| { + !(punct(tokens, *start, ')') || punct(tokens, *start, ']') || *start >= tokens.len()) + }); starts } -/// The text of a literal holds a sensitive JSON key with a string value. -/// 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. +/// 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(); @@ -371,7 +527,7 @@ fn embedded_json_key(text: &str) -> Option { if sensitive(key) && let Some(value) = rest[close + 1..].trim_start().strip_prefix(':') && let Some(value) = value.trim_start().strip_prefix('"') - && !value.starts_with('{') + && !(value.is_empty() || value.starts_with(['{', '"'])) { return Some(key.to_owned()); } @@ -405,7 +561,7 @@ fn scan_source(path: &str, src: &str) -> Vec { eq += 1; } if punct(&tokens, eq, '=') - && let Some(line) = literal_expression(&tokens, eq + 1) + && let Some(line) = literal_in_initializer(&tokens, eq + 1) { flag(line, Shape::Binding, name); } @@ -420,27 +576,15 @@ fn scan_source(path: &str, src: &str) -> Vec { flag(line, Shape::Field, name); } } - Tok::Ident(callee) if punct(&tokens, at + 1, '(') => { + Tok::Ident(callee) => { if at > 0 && ident(&tokens, at - 1) == Some("fn") { continue; } - let args = arguments(&tokens, at + 1); - 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.as_str()) - || (SIGN_IN_FRAGMENTS.iter().any(|f| callee.contains(f)) && literals >= 2)); - let checked: &[usize] = if sensitive(callee) { - &args - } else if last_is_password { - &args[args.len() - 1..] - } else { - &[] + let Some(open) = call_open(&tokens, at) else { + continue; }; - for start in checked { - if let Some(line) = literal_expression(&tokens, *start) { + for start in checked_arguments(&tokens, callee, open) { + if let Some(line) = literal_expression(&tokens, start) { flag(line, Shape::Call, callee); } } @@ -452,6 +596,14 @@ fn scan_source(path: &str, src: &str) -> Vec { { 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); } @@ -462,14 +614,23 @@ fn scan_source(path: &str, src: &str) -> Vec { 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) - .expect("read test directory") + .unwrap_or_else(|err| panic!("read {}: {err}", dir.display())) .map(|entry| entry.expect("directory entry").path()) .collect(); entries.sort(); for path in entries { - if path.is_dir() { + 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); @@ -491,7 +652,8 @@ fn scan_tree(root: &Path) -> (Vec, Vec) { .map(|part| part.as_os_str().to_string_lossy()) .collect::>() .join("/"); - let src = std::fs::read_to_string(&file).expect("read test source"); + 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); } @@ -513,6 +675,81 @@ fn listing(findings: &[Finding]) -> String { .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] @@ -546,154 +783,53 @@ fn no_test_declares_its_own_credential_literal() { ); } -/// One synthetic source per forbidden shape, with the finding it must -/// produce. -fn forbidden_cases() -> Vec<(String, Shape, &'static str)> { - let key = |key: &str, value: &str| format!("json!({{ \"{key}\": {value} }})"); - vec![ - ( - r#"const PASSWORD: &'static str = "x";"#.into(), - Shape::Binding, - "PASSWORD", - ), - ( - r#"static ADMIN_SECRET: &'static str = "x";"#.into(), - Shape::Binding, - "ADMIN_SECRET", - ), - ( - r#"let new_password = "x";"#.into(), - Shape::Binding, - "new_password", - ), - ( - r#"let mut passphrase: String = "x".to_owned();"#.into(), - Shape::Binding, - "passphrase", - ), - ( - r#"let password = String::from("x");"#.into(), - Shape::Binding, - "password", - ), - ( - r##"const PASSWORD: &str = r#"x"#;"##.into(), - Shape::Binding, - "PASSWORD", - ), - (r#"let password = r"x";"#.into(), Shape::Binding, "password"), - // A quote in a character literal opens no string. - ( - r#"let q = '"'; let e = '\''; let password = "x";"#.into(), - Shape::Binding, - "password", - ), - ( - r#"Login { password: "x" }"#.into(), - Shape::Field, - "password", - ), - (key("password", r#""x""#), Shape::JsonKey, "password"), - ( - key("new_password", r#""x""#), - Shape::JsonKey, - "new_password", - ), - ( - key("current_password", r##"r#"x"#"##), - Shape::JsonKey, - "current_password", - ), - ( - format!("let body = {:?};", key("password", r#""x""#)), - Shape::EmbeddedJson, - "password", - ), - ( - r#"secrets::hash_password("x")"#.into(), - Shape::Call, - "hash_password", - ), - ( - r#"secrets::verify_password("x", &phc)"#.into(), - Shape::Call, - "verify_password", - ), - ( - r#"fx.login("avery.admin", "x")"#.into(), - Shape::Call, - "login", - ), - ( - r#"support::sign_in(app, "avery.admin", &"x".to_owned())"#.into(), - Shape::Call, - "sign_in", - ), - ( - r#"users::use_reset_code(&pool, "a", &code, "x")"#.into(), - Shape::Call, - "use_reset_code", - ), - ( - r#"setup::initialize(&p, &c, "A", "a", "A", r"x",)"#.into(), - Shape::Call, - "initialize", - ), - ] -} - -/// Each forbidden shape is found, naming the file and line. +/// 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 each_forbidden_shape_is_found_with_its_file_and_line() { - for (body, shape, subject) in forbidden_cases() { - let src = format!("// line 1\n\n{body}\n"); - let found = scan_source("synthetic.rs", &src); - let expected = Finding { - path: "synthetic.rs".into(), - line: 3, - shape, - subject: subject.into(), - }; +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!( - found.contains(&expected), - "{body}\nexpected {expected}\nfound:\n{}", - listing(&found) + has_case || TREE_LEVEL_MISSES.contains(&item.as_str()), + "not-caught item `{item}` has no `missed` case" ); } } -/// The shapes the sweep uses, the allowlisted fixture, and look-alikes -/// that carry no credential all scan clean. -#[test] -fn allowed_shapes_yield_no_finding() { - let clean = [ - "const PASSWORD_MIN: usize = 12;", - "let password = support::password();", - "static PASSWORD: OnceLock = OnceLock::new();", - "let wrong = support::wrong_password();", - "let fresh = support::fresh_password();", - "fn login(&self, username: &str, password: &str) -> String { todo!() }", - "fx.login(\"avery.admin\", support::password()).await;", - "fx.login(\"casey.api\").await;", - "live_login(&addr, \"avery.admin\").await;", - "secrets::hash_password(support::password()).expect(\"hash\");", - "let secret = text.into_iter().collect();", - "sqlx::query(\"SELECT password_hash FROM user\");", - "(\"user.password_hash\", \"credential\")", - "format!(r#\"{{\"username\":\"{u}\",\"password\":\"{}\"}}\"#, support::password())", - "// const PASSWORD: &str = \"in a comment\";", - "/* let password = \"in a block comment\"; */", - "let c = '\"'; let password = support::password();", - ]; - for src in clean { - let found = scan_source("synthetic.rs", src); - assert!(found.is_empty(), "{src}\nfound:\n{}", listing(&found)); - } - let key = |value: &str| format!("json!({{ \"pass{}\": {value} }})", "word"); - let found = scan_source("synthetic.rs", &key("support::password()")); - assert!(found.is_empty(), "{}", listing(&found)); -} - /// 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. @@ -722,21 +858,30 @@ fn the_allowlist_covers_only_the_named_fixture() { ); } -/// The token boundaries the rules rely on: a literal spanning lines -/// reports its first line, and a quote inside a raw string or comment does -/// not open a literal. +/// 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 literals_are_located_by_their_first_line() { - let src = - "let a = \"one\ntwo\";\n/* \" */\nlet b = r#\"\"\"#;\nconst PASSWORD: &str =\n \"x\";"; - let found = scan_source("synthetic.rs", src); - assert_eq!( - found, - vec![Finding { - path: "synthetic.rs".into(), - line: 6, - shape: Shape::Binding, - subject: "PASSWORD".into(), - }] - ); +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/support/mod.rs b/crates/consolebook-server/tests/support/mod.rs index e0d763c..f5f3075 100644 --- a/crates/consolebook-server/tests/support/mod.rs +++ b/crates/consolebook-server/tests/support/mod.rs @@ -2,10 +2,11 @@ //! credentials (#126). //! //! Every password a test uses comes from here and is generated at run -//! time, once per test process, from OS randomness. No test file -//! declares a password literal, so there is nothing for a static -//! analyzer to report as a hard-coded credential, and -//! `tests/no_credential_literals.rs` fails the build if one returns. +//! 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. diff --git a/crates/consolebook-server/tests/upgrade_compat.rs b/crates/consolebook-server/tests/upgrade_compat.rs index 52e0fae..25cb069 100644 --- a/crates/consolebook-server/tests/upgrade_compat.rs +++ b/crates/consolebook-server/tests/upgrade_compat.rs @@ -130,10 +130,13 @@ 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( support::wrong_password(), PHC_FROM_ARGON2_0_5 diff --git a/crates/consolebook-server/tests/write_transactions.rs b/crates/consolebook-server/tests/write_transactions.rs index 8db2344..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; -mod support; struct Fixture { tmp: tempfile::TempDir, diff --git a/docs/development.md b/docs/development.md index cf38a8a..36185e6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,9 +37,10 @@ tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. `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. Test credentials come from -`tests/support/` (`mod support;`), which generates them at run time; -`tests/no_credential_literals.rs` fails `cargo test` when a test file -declares its own. +`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.