//! §11 tail — per-app opt-out of inherited group templates, end to end via //! `pic`. A group declares a `[[routes]]` template; a descendant app applies a //! `[suppress] routes=[...]` block → the inherited path stops serving (verified //! via `routes match` against the live RouteTable), `pic suppress ls --app` //! shows it, re-apply is a NoOp; a sibling app still serves it; pruning the //! `[suppress]` block re-inherits the route. //! //! The trigger-side filter + the isolation (sibling unaffected, own-trigger //! still fires) are pinned deterministically at the repo layer by //! `manager-core/tests/template_suppression.rs`; this journey exercises the //! authoring + the orchestrator dispatch path the operator uses. use std::fs; use tempfile::TempDir; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard}; fn manifest_dir() -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir"); dir } fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String { let ls = common::pic_as(env) .args(["scripts", "ls", "--group", group]) .output() .expect("scripts ls"); let table = String::from_utf8(ls.stdout).unwrap(); table .lines() .map(common::cells) .find(|c| c.get(2) == Some(&name)) .and_then(|c| c.first().map(|s| (*s).to_string())) .unwrap_or_else(|| panic!("group script `{name}` not found:\n{table}")) } fn route_matches(env: &common::TestEnv, app: &str, url: &str) -> bool { let out = common::pic_as(env) .args(["routes", "match", "--app", app, url]) .output() .expect("routes match"); let stdout = String::from_utf8(out.stdout).unwrap(); stdout.contains("matched") && stdout.contains("true") } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn app_suppresses_inherited_route_template() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("sup-grp"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); // Group handler + a route template binding it. let dir = manifest_dir(); fs::write( dir.path().join("scripts/ghello.rhai"), r#"log::info("group route"); "ok""#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/ghello.rhai")) .args(["--group", &group, "--name", "ghello"]) .assert() .success(); let _gs = ScriptGuard::new( &env.url, &env.token, &group_script_id(&env, &group, "ghello"), ); let gmanifest = format!( "[group]\nslug = \"{group}\"\nname = \"SupG\"\n\n\ [[routes]]\nscript = \"ghello\"\npath = \"/ghello\"\npath_kind = \"exact\"\n\ host_kind = \"any\"\n" ); let gpath = dir.path().join("group.toml"); fs::write(&gpath, &gmanifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&gpath) .assert() .success(); // Two descendant apps: `blog` will suppress, `shop` will not. let blog = common::unique_slug("sup-blog"); let shop = common::unique_slug("sup-shop"); let _ba = AppGuard::new(&env.url, &env.token, &blog); let _sa = AppGuard::new(&env.url, &env.token, &shop); for app in [&blog, &shop] { common::pic_as(&env) .args(["apps", "create", app, "--group", &group]) .assert() .success(); } // Both inherit the template initially. assert!(route_matches(&env, &blog, "http://localhost/ghello")); assert!(route_matches(&env, &shop, "http://localhost/ghello")); // blog applies a [suppress] block declining /ghello. let amanifest = format!( "[app]\nslug = \"{blog}\"\nname = \"Blog\"\n\n\ [suppress]\nroutes = [\"/ghello\"]\n" ); let apath = dir.path().join("app.toml"); fs::write(&apath, &amanifest).unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&apath) .output() .expect("apply blog"); assert!(out.status.success(), "blog apply failed: {out:?}"); // blog no longer serves /ghello; shop still does (per-app boundary). assert!( !route_matches(&env, &blog, "http://localhost/ghello"), "the suppressing app must stop serving the inherited route" ); assert!( route_matches(&env, &shop, "http://localhost/ghello"), "a sibling app that did not suppress must still serve it" ); // `pic suppress ls --app` shows the marker. let ls = String::from_utf8( common::pic_as(&env) .args(["suppress", "ls", "--app", &blog]) .output() .unwrap() .stdout, ) .unwrap(); assert!( ls.contains("route") && ls.contains("/ghello"), "suppress ls should list the route suppression:\n{ls}" ); // Re-apply is a NoOp (marker already present). let report = String::from_utf8( common::pic_as(&env) .args(["apply", "--file"]) .arg(&apath) .output() .unwrap() .stdout, ) .unwrap(); assert!( report.contains("suppressions") && report.contains("+0"), "re-apply should not create a second suppression:\n{report}" ); // A suppress reference matching NO inherited template is a soft warning // (typo guard), not an error — the apply still succeeds. let dangling = format!( "[app]\nslug = \"{blog}\"\nname = \"Blog\"\n\n\ [suppress]\nroutes = [\"/ghello\"]\ntriggers = [\"does-not-exist\"]\n" ); fs::write(&apath, &dangling).unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&apath) .output() .expect("apply dangling"); assert!( out.status.success(), "a dangling suppress must not fail apply" ); let combined = format!( "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); assert!( combined.contains("does-not-exist") && combined.to_lowercase().contains("no effect"), "a dangling suppress must warn that it matches no inherited template:\n{combined}" ); // Drop the [suppress] block + prune → blog re-inherits the route. let bare = format!("[app]\nslug = \"{blog}\"\nname = \"Blog\"\n"); fs::write(&apath, &bare).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&apath) .args(["--prune", "--yes"]) .assert() .success(); assert!( route_matches(&env, &blog, "http://localhost/ghello"), "pruning the suppression must re-inherit the route" ); } /// §11 tail M1: a CHILD group declares `[suppress]` for a template it inherits /// from its PARENT → every app in the child's subtree declines it; a `pic /// suppress ls --group` shows the marker. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn group_suppresses_inherited_route_template_for_subtree() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let parent = common::unique_slug("gsup-p"); let child = common::unique_slug("gsup-c"); let _gp = GroupGuard::new(&env.url, &env.token, &parent); let _gc = GroupGuard::new(&env.url, &env.token, &child); common::pic_as(&env) .args(["groups", "create", &parent]) .assert() .success(); common::pic_as(&env) .args(["groups", "create", &child, "--parent", &parent]) .assert() .success(); // Parent handler + a route template binding it. let dir = manifest_dir(); fs::write( dir.path().join("scripts/phello.rhai"), r#"log::info("parent route"); "ok""#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/phello.rhai")) .args(["--group", &parent, "--name", "phello"]) .assert() .success(); let _ps = ScriptGuard::new( &env.url, &env.token, &group_script_id(&env, &parent, "phello"), ); let pmanifest = format!( "[group]\nslug = \"{parent}\"\nname = \"P\"\n\n\ [[routes]]\nscript = \"phello\"\npath = \"/phello\"\npath_kind = \"exact\"\n\ host_kind = \"any\"\n" ); let ppath = dir.path().join("parent.toml"); fs::write(&ppath, &pmanifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&ppath) .assert() .success(); // App under the CHILD group inherits the parent's route template. let app = common::unique_slug("gsup-app"); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &child]) .assert() .success(); assert!(route_matches(&env, &app, "http://localhost/phello")); // The CHILD group applies a [suppress] declining /phello → the whole child // subtree stops serving it. let cmanifest = format!( "[group]\nslug = \"{child}\"\nname = \"C\"\n\n\ [suppress]\nroutes = [\"/phello\"]\n" ); let cpath = dir.path().join("child.toml"); fs::write(&cpath, &cmanifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&cpath) .assert() .success(); assert!( !route_matches(&env, &app, "http://localhost/phello"), "an app under the suppressing group must stop serving the inherited route" ); // `pic suppress ls --group` shows the child's marker. let ls = String::from_utf8( common::pic_as(&env) .args(["suppress", "ls", "--group", &child]) .output() .unwrap() .stdout, ) .unwrap(); assert!( ls.contains("route") && ls.contains("/phello"), "suppress ls --group should list the route suppression:\n{ls}" ); }