Skip to content

Commit f5d000f

Browse files
committed
chore: merge origin/main into routine/202606151222-gar890-delete-group (resolve plans/README.md conflict)
2 parents b2c0559 + fb7903a commit f5d000f

7 files changed

Lines changed: 525 additions & 2 deletions

File tree

crates/garraia-auth/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,14 @@ required-features = ["test-support"]
140140
name = "extractor"
141141
path = "tests/extractor.rs"
142142
required-features = ["test-support"]
143+
144+
# plan 0347 / GAR-891 (Q6.15) — kills 4 missed mutants from 2026-06-15 run
145+
[[test]]
146+
name = "password_change"
147+
path = "tests/password_change.rs"
148+
required-features = ["test-support"]
149+
150+
[[test]]
151+
name = "audit_workspace_event_integration"
152+
path = "tests/audit_workspace_event_integration.rs"
153+
required-features = ["test-support"]
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//! Integration test for `audit_workspace_event` (plan 0347 / GAR-891 — Q6.15).
2+
//!
3+
//! # Mutant killed
4+
//!
5+
//! * `audit_workspace.rs:897:5` — `replace audit_workspace_event → Ok(())` (shard 0):
6+
//! killed by [`audit_workspace_event_inserts_row`].
7+
//!
8+
//! The production function executes one INSERT into `audit_events` inside the
9+
//! caller's transaction. If the body were replaced with `Ok(())`, no row would
10+
//! be inserted. This test commits the transaction, then queries `audit_events`
11+
//! via the admin pool (bypassing RLS) and asserts exactly one row was created.
12+
//!
13+
//! Caller contract (from audit_workspace.rs:871-883):
14+
//! * Transaction must be on a pool with `INSERT` grant on `audit_events`
15+
//! (`garraia_app` qualifies via migration 007:70).
16+
//! * `SET LOCAL app.current_user_id` and `SET LOCAL app.current_group_id` must
17+
//! be set in the transaction (required by `audit_events_group_or_self` RLS policy).
18+
19+
mod common;
20+
21+
use common::harness::Harness;
22+
use garraia_auth::{WorkspaceAuditAction, audit_workspace_event};
23+
use serde_json::json;
24+
use uuid::Uuid;
25+
26+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
27+
async fn audit_workspace_event_inserts_row() -> anyhow::Result<()> {
28+
let h = Harness::get().await;
29+
let admin = sqlx::PgPool::connect(&h.admin_url).await?;
30+
31+
// Seed a user and a group via the superuser pool (bypass auth + RLS for fixture setup).
32+
let actor_user_id = Uuid::now_v7();
33+
let group_id = Uuid::now_v7();
34+
sqlx::query("INSERT INTO users (id, email, display_name) VALUES ($1, $2, $3)")
35+
.bind(actor_user_id)
36+
.bind(format!("audit-actor-{}@garraia.test", actor_user_id))
37+
.bind("Audit Actor")
38+
.execute(&admin)
39+
.await?;
40+
sqlx::query("INSERT INTO groups (id, name, type, created_by) VALUES ($1, $2, 'team', $3)")
41+
.bind(group_id)
42+
.bind(format!("audit-group-{group_id}"))
43+
.bind(actor_user_id)
44+
.execute(&admin)
45+
.await?;
46+
47+
// Begin a tx on app_pool (garraia_app) and satisfy the GUC contract.
48+
let mut tx = h.app_pool.begin().await?;
49+
sqlx::query("SET LOCAL app.current_user_id = $1")
50+
.bind(actor_user_id.to_string())
51+
.execute(&mut *tx)
52+
.await?;
53+
sqlx::query("SET LOCAL app.current_group_id = $1")
54+
.bind(group_id.to_string())
55+
.execute(&mut *tx)
56+
.await?;
57+
58+
let resource_id = format!("{group_id}:{actor_user_id}");
59+
audit_workspace_event(
60+
&mut tx,
61+
WorkspaceAuditAction::MemberRemoved,
62+
actor_user_id,
63+
group_id,
64+
"group_members",
65+
resource_id.clone(),
66+
json!({ "target_user_id": actor_user_id, "old_role": "member" }),
67+
)
68+
.await?;
69+
tx.commit().await?;
70+
71+
// Verify via admin pool (bypasses RLS; confirms the INSERT was not silenced).
72+
let count: i64 = sqlx::query_scalar(
73+
"SELECT count(*) FROM audit_events \
74+
WHERE action = 'member.removed' \
75+
AND group_id = $1 \
76+
AND actor_user_id = $2 \
77+
AND resource_id = $3",
78+
)
79+
.bind(group_id)
80+
.bind(actor_user_id)
81+
.bind(&resource_id)
82+
.fetch_one(&admin)
83+
.await?;
84+
assert_eq!(
85+
count, 1,
86+
"audit_workspace_event must INSERT exactly 1 row — \
87+
fails if `replace with Ok(())` mutant (audit_workspace.rs:897) is active"
88+
);
89+
Ok(())
90+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//! Integration tests for `change_password` and `anonymize_identity`
2+
//! (plan 0347 / GAR-891 — Q6.15).
3+
//!
4+
//! # Mutants killed
5+
//!
6+
//! * `password.rs:81:8` — `delete !` in `change_password` (shard 2): killed by
7+
//! [`change_password_correct_argon2id_returns_success`].
8+
//! * `password.rs:75:58` — `replace \|\| with &&` in `change_password` (shard 1): killed by
9+
//! [`change_password_correct_pbkdf2sha256_returns_success`].
10+
//! * `password.rs:119:5` — `replace anonymize_identity with Ok(())` (shard 0): killed by
11+
//! [`anonymize_identity_updates_login`].
12+
//!
13+
//! All tests use the shared `Harness` (one pgvector/pg16 container per binary,
14+
//! isolation via unique UUIDs per test).
15+
16+
mod common;
17+
18+
use common::harness::Harness;
19+
use garraia_auth::{PasswordChangeOutcome, anonymize_identity, change_password, hash_argon2id};
20+
use password_hash::{PasswordHasher, SaltString};
21+
use pbkdf2::Pbkdf2;
22+
use secrecy::SecretString;
23+
use sqlx::Row;
24+
use uuid::Uuid;
25+
26+
fn pw(s: &str) -> SecretString {
27+
SecretString::from(s.to_owned())
28+
}
29+
30+
/// Insert one user + one internal identity via the admin pool.
31+
/// Returns the new `user_id`. The `login` column is set to `email` so that
32+
/// `anonymize_identity` tests can detect the change by querying it.
33+
async fn seed(admin: &sqlx::PgPool, email: &str, password_hash: &str) -> anyhow::Result<Uuid> {
34+
let row = sqlx::query("INSERT INTO users (email, display_name) VALUES ($1, $2) RETURNING id")
35+
.bind(email)
36+
.bind(email)
37+
.fetch_one(admin)
38+
.await?;
39+
let user_id: Uuid = row.try_get("id")?;
40+
41+
sqlx::query(
42+
"INSERT INTO user_identities \
43+
(user_id, provider, provider_sub, login, password_hash) \
44+
VALUES ($1, 'internal', $2, $3, $4)",
45+
)
46+
.bind(user_id)
47+
.bind(user_id.to_string())
48+
.bind(email)
49+
.bind(password_hash)
50+
.execute(admin)
51+
.await?;
52+
53+
Ok(user_id)
54+
}
55+
56+
// ── change_password tests ────────────────────────────────────────────────────
57+
58+
/// Kills `password.rs:81:8` — `delete !` mutant.
59+
///
60+
/// The production code has `if !matches { return Ok(WrongPassword); }`.
61+
/// If the `!` were deleted it becomes `if matches { return Ok(WrongPassword); }`,
62+
/// meaning a CORRECT password returns `WrongPassword`. This test panics in that case.
63+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
64+
async fn change_password_correct_argon2id_returns_success() -> anyhow::Result<()> {
65+
let h = Harness::get().await;
66+
let admin = sqlx::PgPool::connect(&h.admin_url).await?;
67+
let email = format!("cp-ok-argon-{}@garraia.test", Uuid::now_v7());
68+
let correct = pw("correct-horse-battery-staple-42");
69+
let hash = hash_argon2id(&correct)?;
70+
let user_id = seed(&admin, &email, &hash).await?;
71+
72+
let outcome = change_password(
73+
&h.login_pool,
74+
user_id,
75+
&correct,
76+
&pw("brand-new-password-99"),
77+
)
78+
.await?;
79+
assert_eq!(
80+
outcome,
81+
PasswordChangeOutcome::Success,
82+
"correct Argon2id password must return Success — \
83+
fails if `delete !` mutant (password.rs:81) is active"
84+
);
85+
Ok(())
86+
}
87+
88+
/// Kills `password.rs:75:58` — `replace \|\| with &&` mutant.
89+
///
90+
/// A PBKDF2-SHA256 PHC string starts with `$pbkdf2-sha256$` but NOT `$pbkdf2$`.
91+
/// The production branch is:
92+
/// ```
93+
/// } else if stored_hash.starts_with("$pbkdf2-sha256$") || stored_hash.starts_with("$pbkdf2$") {
94+
/// ```
95+
/// If `||` were replaced with `&&`, a `$pbkdf2-sha256$` hash would not match
96+
/// (it satisfies the first condition but not the second), causing the function
97+
/// to fall through to `Err(AuthError::UnknownHashFormat)` instead of `Success`.
98+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
99+
async fn change_password_correct_pbkdf2sha256_returns_success() -> anyhow::Result<()> {
100+
let h = Harness::get().await;
101+
let admin = sqlx::PgPool::connect(&h.admin_url).await?;
102+
let email = format!("cp-ok-pbkdf2-{}@garraia.test", Uuid::now_v7());
103+
104+
// Build a real PBKDF2-SHA256 PHC string — same pattern as verify_internal.rs.
105+
let plaintext_str = "legacy-pbkdf2-password-for-change";
106+
let salt = SaltString::generate(&mut password_hash::rand_core::OsRng);
107+
let phc = Pbkdf2
108+
.hash_password(plaintext_str.as_bytes(), &salt)
109+
.expect("PBKDF2 hash must succeed in tests")
110+
.to_string();
111+
// Hard assertion: the prefix must be exactly what production code dispatches on.
112+
assert!(
113+
phc.starts_with("$pbkdf2-sha256$"),
114+
"PBKDF2 PHC must start with $pbkdf2-sha256$ — prefix assumption violated: {phc}"
115+
);
116+
assert!(
117+
!phc.starts_with("$pbkdf2$"),
118+
"PBKDF2-SHA256 must NOT start with bare $pbkdf2$ — test is targeting the wrong mutant"
119+
);
120+
121+
let user_id = seed(&admin, &email, &phc).await?;
122+
let outcome = change_password(
123+
&h.login_pool,
124+
user_id,
125+
&pw(plaintext_str),
126+
&pw("new-password-after-upgrade"),
127+
)
128+
.await?;
129+
assert_eq!(
130+
outcome,
131+
PasswordChangeOutcome::Success,
132+
"PBKDF2-SHA256 correct password must return Success — \
133+
fails if `|| → &&` mutant (password.rs:75) is active"
134+
);
135+
Ok(())
136+
}
137+
138+
/// Baseline: wrong password returns `WrongPassword` (anti-enumeration path).
139+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
140+
async fn change_password_wrong_argon2id_returns_wrong_password() -> anyhow::Result<()> {
141+
let h = Harness::get().await;
142+
let admin = sqlx::PgPool::connect(&h.admin_url).await?;
143+
let email = format!("cp-wrong-{}@garraia.test", Uuid::now_v7());
144+
let real_hash = hash_argon2id(&pw("actual-secret-password"))?;
145+
let user_id = seed(&admin, &email, &real_hash).await?;
146+
147+
let outcome = change_password(
148+
&h.login_pool,
149+
user_id,
150+
&pw("definitely-wrong-password"),
151+
&pw("irrelevant-new-password"),
152+
)
153+
.await?;
154+
assert_eq!(
155+
outcome,
156+
PasswordChangeOutcome::WrongPassword,
157+
"wrong password must return WrongPassword"
158+
);
159+
Ok(())
160+
}
161+
162+
// ── anonymize_identity tests ─────────────────────────────────────────────────
163+
164+
/// Kills `password.rs:119:5` — `replace anonymize_identity → Ok(())` mutant.
165+
///
166+
/// If the function body were replaced with `Ok(())`, `user_identities.login`
167+
/// would remain the original email. This test queries the column directly and
168+
/// panics if the UPDATE was silently skipped.
169+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
170+
async fn anonymize_identity_updates_login() -> anyhow::Result<()> {
171+
let h = Harness::get().await;
172+
let admin = sqlx::PgPool::connect(&h.admin_url).await?;
173+
let email = format!("anon-target-{}@garraia.test", Uuid::now_v7());
174+
let hash = hash_argon2id(&pw("any-password-for-anon"))?;
175+
let user_id = seed(&admin, &email, &hash).await?;
176+
177+
anonymize_identity(&h.login_pool, user_id).await?;
178+
179+
let row = sqlx::query(
180+
"SELECT login FROM user_identities \
181+
WHERE user_id = $1 AND provider = 'internal'",
182+
)
183+
.bind(user_id)
184+
.fetch_one(&admin)
185+
.await?;
186+
let new_login: Option<String> = row.try_get("login")?;
187+
let new_login = new_login.expect(
188+
"login must be non-NULL after anonymize_identity — \
189+
fails if `replace with Ok(())` mutant (password.rs:119) is active",
190+
);
191+
assert!(
192+
new_login.starts_with("anon-"),
193+
"login must start with 'anon-' after anonymization, got: {new_login}"
194+
);
195+
assert!(
196+
new_login.ends_with("@garraanon.local"),
197+
"login must end with '@garraanon.local' after anonymization, got: {new_login}"
198+
);
199+
assert_ne!(
200+
new_login, email,
201+
"login must have changed from the original email"
202+
);
203+
Ok(())
204+
}

docs/security/dependabot-status.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
1-
> Last updated: **2026-06-14 run 136** (health routine — priority (i): all surfaces clean. run 135 (GAR-883) merged PR #772; run 134 (GAR-882) merged PR #769; run 133 (GAR-880) merged PR #764; run 132 (GAR-879) merged PR #763; run 131 (GAR-878) merged PR #760; run 130 (GAR-877) merged PR #758; run 129 (GAR-875) Linear-only status note (no PR); run 128 (GAR-873) merged PR #754; run 127 (GAR-872) merged PR #752; run 126 (GAR-870) merged PR #749; run 125 (GAR-868) merged PR #746 (rebased — plan renumbered 0327→0329); run 124 (GAR-867) merged PR #744; run 123 (GAR-865) merged PR #739; run 122 (GAR-863) merged PR #736; run 121 (GAR-861) merged PR #733; run 120 (GAR-859) merged PR #732; run 119 (GAR-857) merged PR #731; run 118 (GAR-855) merged PR #727; run 117 (GAR-854) merged PR #726; run 116 (GAR-852) merged PR #724; run 115 (GAR-849) merged PR #722; run 114 (GAR-848) merged PR #721; run 113 (GAR-846) clean; run 112 (GAR-843) clean; run 111 (GAR-842) clean; run 110 (GAR-841) merged PR #713; run 109 (GAR-839) merged PR #711; run 108 (GAR-838) merged PR #710; run 107 (GAR-836) clean; run 106 (GAR-833) clean; run 105 (GAR-832) clean; run 104 (GAR-831) merged PR #698; run 103 (GAR-830) merged PR #697; run 102 (GAR-829) clean; run 101 (GAR-828) clean; run 100 (GAR-826) clean; run 99 (GAR-824) merged PR #687; run 97 (GAR-822) CI swagger-ui fix; run 96 (GAR-820) clean; run 93 (GAR-817) priority (h) fix RUSTSEC-2026-0173).
1+
> Last updated: **2026-06-15 run 144** (health routine — priority (i): 4 non-security Dependabot PRs open, all 20/20 CI-green, no CVEs. run 136 (GAR-886) priority (i); run 135 (GAR-883) merged PR #772; run 134 (GAR-882) merged PR #769; run 133 (GAR-880) merged PR #764; run 132 (GAR-879) merged PR #763; run 131 (GAR-878) merged PR #760; run 130 (GAR-877) merged PR #758; run 129 (GAR-875) Linear-only status note (no PR); run 128 (GAR-873) merged PR #754; run 127 (GAR-872) merged PR #752; run 126 (GAR-870) merged PR #749; run 125 (GAR-868) merged PR #746 (rebased — plan renumbered 0327→0329); run 124 (GAR-867) merged PR #744; run 123 (GAR-865) merged PR #739; run 122 (GAR-863) merged PR #736; run 121 (GAR-861) merged PR #733; run 120 (GAR-859) merged PR #732; run 119 (GAR-857) merged PR #731; run 118 (GAR-855) merged PR #727; run 117 (GAR-854) merged PR #726; run 116 (GAR-852) merged PR #724; run 115 (GAR-849) merged PR #722; run 114 (GAR-848) merged PR #721; run 113 (GAR-846) clean; run 112 (GAR-843) clean; run 111 (GAR-842) clean; run 110 (GAR-841) merged PR #713; run 109 (GAR-839) merged PR #711; run 108 (GAR-838) merged PR #710; run 107 (GAR-836) clean; run 106 (GAR-833) clean; run 105 (GAR-832) clean; run 104 (GAR-831) merged PR #698; run 103 (GAR-830) merged PR #697; run 102 (GAR-829) clean; run 101 (GAR-828) clean; run 100 (GAR-826) clean; run 99 (GAR-824) merged PR #687; run 97 (GAR-822) CI swagger-ui fix; run 96 (GAR-820) clean; run 93 (GAR-817) priority (h) fix RUSTSEC-2026-0173).
22
> Source of truth: `.cargo/audit.toml` and `deny.toml` (the suppression
33
> rationale lives there, this file is the alert-to-rationale index).
44
5+
## Confirmed 2026-06-15 run 144 (~12:45 ET) — priority (i): 4 non-security Dependabot PRs open
6+
7+
Health routine ran on 2026-06-15 (~12:45 ET / 16:45 UTC). Priority **(i)** — no CVE-tagged security work found. Four non-security Dependabot maintenance PRs are open and fully CI-green.
8+
9+
**Scan scope:** GitHub Actions CI on main (last 20+ runs), Dependabot PRs (4 open, non-security), security surfaces: CI Security Audit, CI cargo-deny, CI CodeQL, CI gitleaks.
10+
11+
| Surface | Status | Detail |
12+
|---|---|---|
13+
| Secret scanning (gitleaks) | ✅ clean | CI success on main `f622d9c` (2026-06-15T13:28Z) |
14+
| Malware (cargo/npm) | ✅ none | cargo-deny CI success |
15+
| Dependabot PRs | ⚠️ 4 non-security open | #781 @playwright/test 1.60→1.61, #782 uuid/chrono/regex/aws-sdk-s3/aws-smithy-types patch-minor, #783 lopdf 0.40→0.41, #784 tower-http 0.6.11→0.7.0 — all 20/20 CI-green, no CVEs |
16+
| Dependabot security alerts | ⚠️ 3 open, all upstream-blocked | rsa (RUSTSEC-2023-0071/GAR-456), glib (RUSTSEC-2024-0429/GAR-513), rand (RUSTSEC-2026-0097/GAR-513) — expiry 2026-07-31. No first_patched_version for any. |
17+
| Security Audit (cargo-audit) | ✅ pass | CI success on main `f622d9c`; 0 vulnerabilities, 0 unsound |
18+
| cargo-deny | ✅ pass | Suppressed in deny.toml |
19+
| CodeQL | ✅ pass | Analyze (rust) + (javascript-typescript) + (actions) success on main `f622d9c` |
20+
| CI on main (`f622d9c`) | ✅ green | All workflow checks success (2026-06-15T13:28Z) |
21+
22+
**Open PRs noted:** PR #779 (`routine/202606151222-gar890-delete-group`, GAR-890) — NOT touched (routine/ prefix). Dependabot PRs #781#784 all CI-green — safe to merge when owner is ready; tower-http #784 is MAJOR but use-sites (ServeDir::new + CorsLayer) are unaffected (confirmed by clippy+build+MSRV passing).
23+
24+
**Next security backlog:** rsa RUSTSEC-2023-0071 (GAR-456, expiry 2026-07-31), glib RUSTSEC-2024-0429 (GAR-513, expiry 2026-07-31), rand RUSTSEC-2026-0097 (GAR-513, expiry 2026-07-31), CodeQL ledger re-audit due 2026-08-01 (GAR-491).
25+
26+
---
27+
528
## Confirmed 2026-06-14 run 136 (~20:45 ET) — priority (i): all surfaces clean
629

730
Health routine ran on 2026-06-14 (~20:45 ET / 2026-06-15T00:45 UTC). Priority **(i)** — no actionable security work found.

0 commit comments

Comments
 (0)