Files
PiCloud/crates/manager-core/tests/email_secret_aad.rs
MechaCat02 345db4a076 test: close the DB-suite hermeticity + vacuous-skip gaps
Three related fixes from the test audit.

**The CLI journey fixture gets its own database.** It spawned a real picloud —
whose dispatcher/orchestrator claim loops are global by design (one instance owns
one database) — against the shared dev DB, so it could claim the manager-core
suites' outbox/workflow rows (the same class of bug already fixed for the e2e
suites, one binary over). It now clones one dedicated database per journey run
from the migrated template. test-support gains `named_test_db_url` (explicit
stable name) + a blocking wrapper for the sync `LazyLock` fixture. The one journey
that talks to Postgres directly (dead-letter injection) now uses the fixture's DB
URL, not the base DATABASE_URL, so it hits the database the server reads.

**workflow_orchestrator moves to per-test databases.** Its `claim_ready_step` is
global, so the old harness serialized every test behind a process-wide CLAIM_LOCK
AND ran `DELETE FROM workflow_runs` (unscoped — it wiped every app's runs) before
each one. A private database per test makes the global claim see only that test's
rows, so both the lock and the unscoped DELETE are deleted.

**DB-backed suites fail loud instead of skipping green.** ~15 manager-core suites
`return None` when DATABASE_URL is unset and report PASS — so in any environment
that lost its database the entire integration surface reports green while running
nothing (why the CI gap went unnoticed for so long). New
`picloud_test_support::abort_if_db_required` panics when `PICLOUD_REQUIRE_DB` is
set (CI now sets it) but DATABASE_URL is not, injected into each suite's skip
path. Local runs without the var still skip cleanly.

Mutation-verified: with PICLOUD_REQUIRE_DB=1 and DATABASE_URL unset, a suite
panics; without the var it skips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:39:13 +02:00

193 lines
6.8 KiB
Rust

//! §M5.5 / audit 2026-06-11 H-D1: a MATERIALIZED group email-template copy
//! decrypts its inbound secret under the recovered GROUP AAD.
//!
//! This pins M3's novel path: a group email template seals its inbound secret v1
//! with AAD bound to the GROUP; the materializer copies the sealed bytes +
//! version verbatim into a per-app copy (app_id set, `materialized_from` = the
//! template); at inbound time `email_inbound_target` must recover the sealing
//! group via `materialized_from` so `open_email` opens the group-sealed bytes.
//! Sealing under the app instead (this copy's own owner) would fail the GCM tag.
//!
//! Skips cleanly when `DATABASE_URL` is unset.
#![allow(clippy::too_many_lines)]
use picloud_manager_core::secrets_service::{open_email, seal_email, SecretOwner};
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
use picloud_shared::{GroupId, MasterKey};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
picloud_test_support::abort_if_db_required("email_secret_aad");
eprintln!("email_secret_aad: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate");
Some(pool)
}
fn uniq(p: &str) -> String {
format!("{p}-{}", &Uuid::new_v4().to_string()[..8])
}
#[tokio::test]
async fn materialized_email_copy_decrypts_under_the_group_aad() {
let Some(pool) = pool_or_skip().await else {
return;
};
let key = MasterKey::from_bytes([7u8; 32]);
// A group + a descendant app + a group-owned handler script.
let (gid,): (Uuid,) =
sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
.bind(uniq("esa-g"))
.fetch_one(&pool)
.await
.unwrap();
let (app_id,): (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(uniq("esa-a"))
.bind(gid)
.fetch_one(&pool)
.await
.unwrap();
let (admin_id,): (Uuid,) = sqlx::query_as(
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
)
.bind(uniq("esa-u"))
.fetch_one(&pool)
.await
.unwrap();
let (script_id,): (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (group_id, name, source) \
VALUES ($1, $2, 'log::info(\"x\")') RETURNING id",
)
.bind(gid)
.bind(uniq("inbound"))
.fetch_one(&pool)
.await
.unwrap();
// Seal the inbound secret v1 under the GROUP (the shared-group-secret model),
// and insert the group EMAIL TEMPLATE (group_id set, app_id NULL).
let secret = serde_json::Value::String("group-hmac-key".into());
let (ct, nonce, version) =
seal_email(&key, SecretOwner::Group(GroupId::from(gid)), &secret, 65536).unwrap();
assert_eq!(version, 1);
let (tmpl_id,): (Uuid,) = sqlx::query_as(
"INSERT INTO triggers (group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, registered_by_principal) \
VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
)
.bind(gid)
.bind(script_id)
.bind(admin_id)
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version) \
VALUES ($1, $2, $3, $4)",
)
.bind(tmpl_id)
.bind(&ct)
.bind(nonce.to_vec())
.bind(version)
.execute(&pool)
.await
.unwrap();
// Materialize a per-app copy: app-owned, `materialized_from` = the template,
// sealed bytes + version copied VERBATIM (no reseal — the AAD is the group's).
let (copy_id,): (Uuid,) = sqlx::query_as(
"INSERT INTO triggers (app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, registered_by_principal, \
materialized_from) \
VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
)
.bind(app_id)
.bind(script_id)
.bind(admin_id)
.bind(tmpl_id)
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version) \
SELECT $1, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version \
FROM email_trigger_details WHERE trigger_id = $2",
)
.bind(copy_id)
.bind(tmpl_id)
.execute(&pool)
.await
.unwrap();
// The inbound target for the COPY recovers the sealing group + version, and
// the group-sealed bytes decrypt under Group(gid) — the exact path the
// webhook receiver takes.
let repo = PostgresTriggerRepo::new(pool.clone());
let target = repo
.email_inbound_target(copy_id.into())
.await
.unwrap()
.expect("materialized copy is a valid inbound target");
assert_eq!(
target.sealing_group.map(GroupId::into_inner),
Some(gid),
"the copy recovers its template's group as the sealing owner"
);
assert_eq!(target.inbound_secret_version, 1);
let owner = SecretOwner::Group(target.sealing_group.unwrap());
let recovered = open_email(
&key,
owner,
target.inbound_secret_encrypted.as_ref().unwrap(),
target.inbound_secret_nonce.as_ref().unwrap(),
target.inbound_secret_version,
)
.expect("group-sealed bytes decrypt under the recovered group AAD");
assert_eq!(recovered, secret);
// Opening under the COPY's OWN app owner (the wrong owner) must fail — proof
// the AAD binds to the group, not the per-app row.
assert!(
open_email(
&key,
SecretOwner::App(app_id.into()),
target.inbound_secret_encrypted.as_ref().unwrap(),
target.inbound_secret_nonce.as_ref().unwrap(),
target.inbound_secret_version,
)
.is_err(),
"the group-sealed secret must NOT open under the copy's app owner"
);
// Cleanup (triggers/details/scripts cascade on group+app delete).
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(app_id)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(gid)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(admin_id)
.execute(&pool)
.await;
}