//! §11 tail integration test: per-app opt-out of inherited group templates. //! An app that declares a suppression must NOT match the inherited TRIGGER //! (dispatch anti-join) nor serve the inherited ROUTE (rebuild-time skip); a //! sibling app with no suppression still inherits both. Suppression is //! inheritance-only — an app's OWN trigger on the suppressed handler still //! fires. //! //! Deterministic: drives `list_matching_kv` + `list_effective` / //! `compile_effective_routes` directly (no async dispatcher). Skips cleanly //! when `DATABASE_URL` is unset. #![allow( clippy::needless_pass_by_value, clippy::many_single_char_names, clippy::too_many_lines )] use std::collections::HashSet; use picloud_manager_core::route_admin::compile_effective_routes; use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository}; use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo}; use picloud_shared::{AppId, KvEventOp}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use uuid::Uuid; async fn pool_or_skip() -> Option { let Ok(url) = std::env::var("DATABASE_URL") else { eprintln!("template_suppression: DATABASE_URL unset — skipping"); return None; }; let pool = PgPoolOptions::new() .max_connections(2) .connect(&url) .await .expect("connect to DATABASE_URL"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("apply migrations"); Some(pool) } async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid { let row: (Uuid,) = sqlx::query_as(sql) .bind(bind) .fetch_one(pool) .await .expect("insert returning id"); row.0 } async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid { let row: (Uuid,) = sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") .bind(slug) .bind(group) .fetch_one(pool) .await .expect("app insert"); row.0 } /// Whether `app`'s compiled route slice serves `/hello`. async fn serves_hello(repo: &PostgresRouteRepository, app: Uuid) -> bool { let effective = repo.list_effective().await.expect("list_effective"); let suppressed: HashSet<(AppId, String)> = repo .list_route_suppressions() .await .expect("list_route_suppressions") .into_iter() .collect(); compile_effective_routes(&effective, &suppressed) .into_iter() .any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains("/hello")) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn suppression_declines_inherited_trigger_and_route_only_for_declaring_app() { let Some(pool) = pool_or_skip().await else { return; }; let sfx = Uuid::new_v4().simple().to_string(); let admin = id1( &pool, "INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id", &format!("ts-{sfx}"), ) .await; // Group G owns a handler `audit` + a kv trigger template + a /hello route // template, both bound to it. let g = id1( &pool, "INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id", &format!("ts-g-{sfx}"), ) .await; let handler: (Uuid,) = sqlx::query_as( "INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id", ) .bind(format!("audit-{sfx}")) .bind(g) .fetch_one(&pool) .await .expect("group handler"); let handler_name = format!("audit-{sfx}"); let tmpl: (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, name) \ VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id", ) .bind(g) .bind(handler.0) .bind(admin) .bind(format!("tmpl-{sfx}")) .fetch_one(&pool) .await .expect("kv template"); sqlx::query( "INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \ VALUES ($1, '*', ARRAY['insert'])", ) .bind(tmpl.0) .execute(&pool) .await .expect("kv details"); sqlx::query( "INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \ VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)", ) .bind(g) .bind(handler.0) .execute(&pool) .await .expect("route template"); // App A (suppresses both) + app B (no suppression), both under G. let a = app_under(&pool, &format!("ts-a-{sfx}"), g).await; let b = app_under(&pool, &format!("ts-b-{sfx}"), g).await; sqlx::query( "INSERT INTO template_suppressions (app_id, target_kind, reference) \ VALUES ($1, 'trigger', $2), ($1, 'route', '/hello')", ) .bind(a) .bind(&handler_name) .execute(&pool) .await .expect("suppressions for A"); let trig = PostgresTriggerRepo::new(pool.clone()); let routes = PostgresRouteRepository::new(pool.clone()); // A suppressed → neither the inherited trigger matches nor the route serves. let a_trig = trig .list_matching_kv(AppId::from(a), "users", KvEventOp::Insert) .await .expect("match A"); assert!( !a_trig.iter().any(|m| m.trigger_id == tmpl.0.into()), "the suppressing app must NOT match the inherited trigger" ); assert!( !serves_hello(&routes, a).await, "the suppressing app must NOT serve the inherited route" ); // B did not suppress → still inherits both. let b_trig = trig .list_matching_kv(AppId::from(b), "users", KvEventOp::Insert) .await .expect("match B"); assert!( b_trig.iter().any(|m| m.trigger_id == tmpl.0.into()), "a sibling app that did not suppress still inherits the trigger" ); assert!( serves_hello(&routes, b).await, "a sibling app that did not suppress still serves the route" ); // Suppression is inheritance-only: A's OWN kv trigger on the same handler // name (a distinct app-owned script) still fires despite the suppression. let a_own_script: (Uuid,) = sqlx::query_as( "INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id", ) .bind(&handler_name) .bind(a) .fetch_one(&pool) .await .expect("A own script"); let a_own_trig: (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, name) \ VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id", ) .bind(a) .bind(a_own_script.0) .bind(admin) .bind(format!("own-{sfx}")) .fetch_one(&pool) .await .expect("A own trigger"); sqlx::query( "INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \ VALUES ($1, '*', ARRAY['insert'])", ) .bind(a_own_trig.0) .execute(&pool) .await .expect("A own kv details"); let a_after = trig .list_matching_kv(AppId::from(a), "users", KvEventOp::Insert) .await .expect("match A after own trigger"); assert!( a_after.iter().any(|m| m.trigger_id == a_own_trig.0.into()), "the app's OWN trigger fires — suppression only declines inherited ones" ); assert!( !a_after.iter().any(|m| m.trigger_id == tmpl.0.into()), "the inherited trigger stays suppressed" ); // Cleanup (FK order). let _ = sqlx::query("DELETE FROM triggers WHERE id = ANY($1)") .bind(vec![tmpl.0, a_own_trig.0]) .execute(&pool) .await; let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1") .bind(g) .execute(&pool) .await; let _ = sqlx::query("DELETE FROM template_suppressions WHERE app_id = $1") .bind(a) .execute(&pool) .await; let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)") .bind(vec![handler.0, a_own_script.0]) .execute(&pool) .await; let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)") .bind(vec![a, b]) .execute(&pool) .await; let _ = sqlx::query("DELETE FROM groups WHERE id = $1") .bind(g) .execute(&pool) .await; let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1") .bind(admin) .execute(&pool) .await; }