//! Tests for [`super`]. use super::{ BuildArtifact, accept_intake, check_build_host, checkout_aux_repos, gate_intake, runtime_hostname, stage_and_gate, tail, }; use crate::config::{AppConfig, TestTarget}; use crate::domain::{GitSha, RunId, Version}; use crate::topology::{AuxRepo, BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology}; use sqlx::SqlitePool; use sqlx::sqlite::SqlitePoolOptions; use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; /// Post-build pipeline fixture: an in-memory store with the `host` tier /// seeded, a synthetic worktree holding a fake primary binary, and a /// build_runs row in flight. Returns everything `stage_and_gate` needs plus /// the tempdir root (drop it to clean up) and the run/version it seeded. /// /// `gates` is the host tier's gate list: `[]` is the green path; /// `[Gate::ManualConfirm]` is a deterministic red — with no prior operator /// confirmation row that gate blocks, and it shells out to nothing. async fn stage_fixture( gates: Vec, ) -> ( SqlitePool, Arc, Arc, BuildArtifact, RunId, Version, tempfile::TempDir, ) { let tmp = tempfile::tempdir().unwrap(); let release_root = tmp.path().join("release-root"); let worktree = tmp.path().join("worktree"); let bin_dir = worktree.join("target").join("release"); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); let bin_path = bin_dir.join("makenotwork"); tokio::fs::write(&bin_path, b"#!/bin/false\nfake sando artifact\n") .await .unwrap(); let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!("./migrations").run(&pool).await.unwrap(); // gate_runs and tier_state FK into `tiers`; the pipeline only touches host. sqlx::query( "INSERT INTO tiers (name, ord, provisioned, canary) VALUES ('host', 0, 1, 'sequential')", ) .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO tier_state (tier) VALUES ('host')") .execute(&pool) .await .unwrap(); let version = Version::parse("1.2.3").unwrap(); let git_sha = GitSha::parse("abc1234").unwrap(); // gate_runs.version and the `SET artifact_path` UPDATE both need the row. sqlx::query( "INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, ?, datetime('now'), '')", ) .bind(version.to_string()) .bind(git_sha.to_string()) .execute(&pool) .await .unwrap(); let run_id = crate::runs::create( &pool, &crate::domain::AppId::default(), &git_sha.to_string(), ) .await .unwrap(); let cfg = AppConfig { page_smoke_cmd: None, platform: None, code_smoke_env: BTreeMap::default(), id: crate::domain::AppId::default(), topology_path: PathBuf::from("/tmp/test-sando.toml"), build_host: Some("test-host".into()), workdir: tmp.path().to_path_buf(), release_root: release_root.clone(), scratch_db_url: None, scratch_owner_role: "makenotwork".into(), boot_smoke_port: 18181, code_smoke_port: 18182, bin_names: vec!["makenotwork".into()], logs_root: tmp.path().join("logs"), release_contents: vec![], cargo_target_dir: None, gate_timeout_secs: 2400, companions: Vec::new(), test_targets: vec![TestTarget { dir: PathBuf::from("server"), aux_repo: None, features: vec!["fast-tests".into()], all_features: false, scratch_db: true, }], migration_checks: vec![], frontend_builds: vec![], backup_max_age_hours: 48, }; let topo = Topology { repo: Some(RepoConfig { bare_path: "/tmp/test.git".into(), branch: "main".into(), upstream: None, }), backup: vec![BackupConfig { name: "server".into(), source: "file:///tmp/test-backup.sql".into(), local_path: "/tmp/local-backup.sql".into(), }], tiers: vec![Tier { public_url: None, name: "host".into(), provisioned: true, gates, canary: CanaryPolicy::Sequential, nodes: Vec::new(), }], aux_repos: Vec::new(), }; let art = BuildArtifact { version: version.clone(), git_sha, worktree, binary_paths: vec![bin_path], companion_paths: Vec::new(), }; ( pool, Arc::new(cfg), Arc::new(topo), art, run_id, version, tmp, ) } // ---- intake through the seam ---- /// The `ArtifactRecord` Bento would have written for a staged bundle. async fn record_for(staged: &std::path::Path, version: &str, target: &str) -> String { use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict}; let computed = crate::bundle::digest_dir(staged).await.unwrap(); let manifest = Manifest::parse(&computed.manifest).unwrap(); let at = chrono::DateTime::::from_timestamp(1_754_000_000, 0).unwrap(); ArtifactRecord::new( "bento", manifest, Provenance { app: "pom".into(), version: version.into(), tag: format!("pom-v{version}"), git_sha: "a".repeat(40), target: target.into(), build_host: "astra".into(), toolchain: "rustc 1.97.0".into(), built_at: at, }, vec![GateRecord::new( "prebuild", Scope::Artifact, Verdict::Passed, "prebuild passed in 90s", at, )], ) .unwrap() .to_json() } #[tokio::test] async fn an_accepted_artifact_is_published_gated_and_advances_the_tier() { // The seam: an artifact Sando did not build reaches the same published, // gated, tier-advanced end state a Sando-built one does. No worktree // exists anywhere in this test, which is the point — everything from // `finalize_local_release` onward stopped caring where the bytes came // from. let (pool, cfg, topo, _art, run_id, _version, tmp) = stage_fixture(vec![]).await; let deploy_lock = Arc::new(tokio::sync::Mutex::new(())); let staged = cfg.release_root.join("staging").join("intake-1"); tokio::fs::create_dir_all(&staged).await.unwrap(); tokio::fs::write(staged.join("makenotwork"), b"bytes built elsewhere") .await .unwrap(); let record = record_for(&staged, "1.2.3", "linux/aarch64").await; // Two calls now, on purpose: the route answers its caller on the first // and spawns the second. Acceptance is what the producer waits for. let published = accept_intake(&pool, &cfg, &staged, &record, run_id) .await .expect("the bytes are believed"); gate_intake( pool.clone(), cfg.clone(), topo, published, crate::events::channel(), run_id, deploy_lock, ) .await .expect("a green intake settles the run"); // Published content-addressed, and the staging dir is gone: renamed, // not copied. let (digest, staged_path, platform): (Option, Option, Option) = sqlx::query_as("SELECT bundle_digest, staged_path, platform FROM build_runs WHERE id = ?") .bind(run_id.0) .fetch_one(&pool) .await .unwrap(); let digest = digest.expect("bundle_digest recorded"); let staged_path = staged_path.expect("staged_path recorded"); assert_eq!(digest.len(), 64); assert!( !staged.exists(), "staging was renamed into the release root" ); assert_eq!( std::path::Path::new(&staged_path), tmp.path() .join("release-root") .join("releases") .join(&digest[..16]), ); // The platform came off the record's provenance and is on the row. This // is what makes two bundles of one version tellable apart later. assert_eq!(platform.as_deref(), Some("linux/aarch64")); // Green gates advanced the host tier, exactly as a build would have. let (result, _summary) = run_result(&pool, run_id).await; assert_eq!(result, "passed"); let (current, _prev) = tier_versions(&pool, "host").await; assert_eq!(current.as_deref(), Some("1.2.3")); } #[tokio::test] async fn an_intake_whose_bytes_drifted_never_reaches_the_gates() { // Identity is decided before anything else happens to the bundle, so a // record vouching for one set of bytes arriving with another fails the // run rather than gating and shipping. let (pool, cfg, topo, _art, run_id, _version, _tmp) = stage_fixture(vec![]).await; let deploy_lock = Arc::new(tokio::sync::Mutex::new(())); let staged = cfg.release_root.join("staging").join("intake-1"); tokio::fs::create_dir_all(&staged).await.unwrap(); tokio::fs::write(staged.join("makenotwork"), b"bytes built elsewhere") .await .unwrap(); let record = record_for(&staged, "1.2.3", "linux/aarch64").await; tokio::fs::write(staged.join("makenotwork"), b"other bytes entirely") .await .unwrap(); // Refused by ACCEPTANCE, not by gating — which is what lets the route // answer the producer with the refusal instead of a `202`-shaped lie. let _ = (&topo, &deploy_lock); let err = accept_intake(&pool, &cfg, &staged, &record, run_id) .await .expect_err("a drifted bundle is refused"); assert!(err.to_string().contains("makenotwork"), "{err}"); // Nothing advanced, and the bytes were left where they were. let (current, _prev) = tier_versions(&pool, "host").await; assert_eq!(current, None); assert!(staged.exists(), "a refused intake leaves the bytes alone"); } #[tokio::test] async fn a_gate_that_reads_source_refuses_against_an_accepted_artifact() { // The boundary showing up at runtime. `code_smoke` is artifact-scoped — // it compiles frontends and boots the binary against a scratch DB — and // an accepted artifact has no checkout for it to read. It has to say so // rather than pass on having run nothing, which is what an unwrapped // `worktree.join(..)` against an empty path would have done. let (pool, cfg, _topo, _art, run_id, version, _tmp) = stage_fixture(vec![]).await; let ctx = crate::gates::GateCtx { pool, cfg, tier: crate::domain::TierId::new("host"), version, worktree: None, bundle: Some(PathBuf::from("/r/abc")), events: crate::events::channel(), nodes: Vec::new(), build_id: Some(run_id.0), public_url: None, aux_dirs: std::collections::HashMap::default(), }; let outcome = ctx .worktree_for(crate::domain::GateKind::CodeSmoke) .expect_err("no worktree means no source-reading gate"); assert!(!outcome.is_passed()); let crate::outcome::GateStatus::Failed { failure } = &outcome.status else { panic!("expected a failure, got {:?}", outcome.status) }; assert!( matches!(failure, crate::outcome::GateFailure::NeedsSource { .. }), "{failure:?}" ); assert!( failure.summary().contains("built elsewhere"), "{}", failure.summary() ); } #[tokio::test] async fn migrations_come_from_the_bundle_before_the_worktree() { // What the gate proves has to be what ships. Migrations staged into the // bundle are inside its digest; the same files sitting in a checkout are // not, and a checkout can be edited between the dry run and the deploy. // So when both hold a copy, the bundle wins. let (pool, cfg, _topo, _art, run_id, version, tmp) = stage_fixture(vec![]).await; let bundle = tmp.path().join("bundle"); let worktree = tmp.path().join("wt"); for root in [&bundle, &worktree] { tokio::fs::create_dir_all(root.join("server/migrations")) .await .unwrap(); } let ctx = crate::gates::GateCtx { pool, cfg, tier: crate::domain::TierId::new("host"), version, worktree: Some(worktree.clone()), bundle: Some(bundle.clone()), events: crate::events::channel(), nodes: Vec::new(), build_id: Some(run_id.0), public_url: None, aux_dirs: std::collections::HashMap::default(), }; assert_eq!( ctx.migrations_dir(std::path::Path::new("server/migrations")), Some(bundle.join("server/migrations")), ); // The worktree is the fallback for a build whose config has not opted // into bundling them yet, which is every MNW build before this lands. let ctx = crate::gates::GateCtx { bundle: Some(tmp.path().join("empty-bundle")), ..ctx }; assert_eq!( ctx.migrations_dir(std::path::Path::new("server/migrations")), Some(worktree.join("server/migrations")), ); // And neither is not silently green: an accepted artifact whose builder // did not bundle its migrations has nothing to dry-run, and the gate // has to be told so rather than restore a dump and report success. let ctx = crate::gates::GateCtx { worktree: None, ..ctx }; assert_eq!( ctx.migrations_dir(std::path::Path::new("server/migrations")), None ); } // ---- checkout_aux_repos ---- async fn git_in(dir: &std::path::Path, args: &[&str]) { let out = tokio::process::Command::new("git") .args(["-c", "user.email=t@t", "-c", "user.name=t"]) .current_dir(dir) .args(args) .output() .await .unwrap(); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); } /// A minimal `Config` whose only field this test path reads is `workdir`. fn cfg_with_workdir(workdir: PathBuf) -> AppConfig { AppConfig { page_smoke_cmd: None, platform: None, code_smoke_env: BTreeMap::default(), id: crate::domain::AppId::default(), topology_path: PathBuf::from("/tmp/test-sando.toml"), build_host: Some("test-host".into()), workdir, release_root: PathBuf::from("/tmp/rr"), scratch_db_url: None, scratch_owner_role: "makenotwork".into(), boot_smoke_port: 18181, code_smoke_port: 18182, bin_names: vec!["makenotwork".into()], logs_root: PathBuf::from("/tmp/logs"), release_contents: vec![], cargo_target_dir: None, gate_timeout_secs: 2400, companions: Vec::new(), test_targets: vec![], migration_checks: vec![], frontend_builds: vec![], backup_max_age_hours: 48, } } fn topo_with_aux(aux_repos: Vec) -> Topology { Topology { repo: Some(RepoConfig { bare_path: "/tmp/x.git".into(), branch: "main".into(), upstream: None, }), backup: vec![BackupConfig { name: "server".into(), source: "s".into(), local_path: "/tmp/d".into(), }], tiers: vec![], aux_repos, } } #[tokio::test] async fn a_gate_looks_where_the_aux_checkout_actually_landed() { // The two halves of the aux-repo test_target path: checkout_aux_repos // writes the tree, and GateCtx::target_dir reads it. Nothing but this // stops one from being changed without the other, and the failure would // be a warn-and-skip — a green gate that ran one crate fewer. let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("docengine-src"); tokio::fs::create_dir_all(&src).await.unwrap(); git_in(&src, &["init", "-q", "-b", "main"]).await; tokio::fs::write(src.join("Cargo.toml"), b"[package]\nname = \"docengine\"\n") .await .unwrap(); git_in(&src, &["add", "."]).await; git_in(&src, &["commit", "-q", "-m", "one"]).await; let workdir = tmp.path().join("work"); tokio::fs::create_dir_all(&workdir).await.unwrap(); let cfg = cfg_with_workdir(workdir.clone()); let topo = topo_with_aux(vec![AuxRepo { name: "docengine".into(), bare_path: tmp .path() .join("docengine.git") .to_string_lossy() .into_owned(), upstream: src.to_string_lossy().into_owned(), branch: "main".into(), // Nested, as the real one is: it must not be mistaken for a path // under the per-sha worktree. checkout_dir: "Libraries/docengine".into(), }]); checkout_aux_repos(&cfg, &topo).await.unwrap(); let ctx = crate::gates::GateCtx { pool: sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(), cfg: Arc::new(cfg.clone()), tier: crate::domain::TierId::new("host"), version: "0.1.0".parse().unwrap(), worktree: Some(workdir.join("abc123")), bundle: None, events: crate::events::channel(), nodes: Vec::new(), build_id: None, public_url: None, aux_dirs: super::aux_checkout_dirs(&cfg, &topo), }; let target = crate::config::TestTarget { dir: PathBuf::new(), aux_repo: Some("docengine".into()), features: Vec::new(), all_features: true, scratch_db: false, }; let resolved = ctx.target_dir(&target).expect("aux repo is checked out"); assert!( resolved.join("Cargo.toml").is_file(), "gate would skip the aux target as absent; resolved {}", resolved.display(), ); assert!( !resolved.starts_with(ctx.worktree.as_ref().unwrap()), "an aux checkout is a sibling of the worktree, not under it", ); } #[tokio::test] async fn checkout_aux_repos_places_repo_beside_worktree_and_refreshes_to_branch_head() { let tmp = tempfile::tempdir().unwrap(); // An "upstream" source repo with a marker file on main. let src = tmp.path().join("synckit-src"); tokio::fs::create_dir_all(&src).await.unwrap(); git_in(&src, &["init", "-q", "-b", "main"]).await; tokio::fs::write(src.join("VERSION"), b"v1").await.unwrap(); git_in(&src, &["add", "."]).await; git_in(&src, &["commit", "-q", "-m", "one"]).await; let workdir = tmp.path().join("work"); tokio::fs::create_dir_all(&workdir).await.unwrap(); let cfg = cfg_with_workdir(workdir.clone()); let topo = topo_with_aux(vec![AuxRepo { name: "synckit".into(), bare_path: tmp .path() .join("synckit.git") .to_string_lossy() .into_owned(), upstream: src.to_string_lossy().into_owned(), branch: "main".into(), checkout_dir: "synckit".into(), }]); // First build: the aux repo lands at workdir/synckit at v1. checkout_aux_repos(&cfg, &topo).await.unwrap(); let dest = workdir.join("synckit"); assert_eq!( tokio::fs::read(dest.join("VERSION")).await.unwrap(), b"v1", "aux repo checked out beside the worktree", ); // Upstream advances; a later build refreshes the shared checkout to HEAD. tokio::fs::write(src.join("VERSION"), b"v2").await.unwrap(); git_in(&src, &["add", "."]).await; git_in(&src, &["commit", "-q", "-m", "two"]).await; checkout_aux_repos(&cfg, &topo).await.unwrap(); assert_eq!( tokio::fs::read(dest.join("VERSION")).await.unwrap(), b"v2", "aux checkout refreshed to the new branch HEAD", ); // The aux bare carries no build-trigger hook. assert!( !tmp.path().join("synckit.git/hooks/post-receive").exists(), "aux bare must be hookless", ); } #[tokio::test] async fn checkout_aux_repos_is_a_noop_without_aux_repos() { let tmp = tempfile::tempdir().unwrap(); let cfg = cfg_with_workdir(tmp.path().to_path_buf()); checkout_aux_repos(&cfg, &topo_with_aux(vec![])) .await .unwrap(); } #[tokio::test] async fn checkout_aux_repos_fails_on_an_unresolvable_branch() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("src"); tokio::fs::create_dir_all(&src).await.unwrap(); git_in(&src, &["init", "-q", "-b", "main"]).await; tokio::fs::write(src.join("f"), b"x").await.unwrap(); git_in(&src, &["add", "."]).await; git_in(&src, &["commit", "-q", "-m", "c"]).await; let cfg = cfg_with_workdir(tmp.path().join("work")); let topo = topo_with_aux(vec![AuxRepo { name: "synckit".into(), bare_path: tmp.path().join("s.git").to_string_lossy().into_owned(), upstream: src.to_string_lossy().into_owned(), branch: "nonexistent".into(), checkout_dir: "synckit".into(), }]); let err = checkout_aux_repos(&cfg, &topo).await.unwrap_err(); assert!( format!("{err:#}").contains("synckit"), "error names the aux repo: {err:#}", ); } async fn tier_versions(pool: &SqlitePool, tier: &str) -> (Option, Option) { sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?") .bind(tier) .fetch_one(pool) .await .unwrap() } async fn run_result(pool: &SqlitePool, run_id: RunId) -> (String, Option) { sqlx::query_as("SELECT result, failure_summary FROM build_runs WHERE id = ?") .bind(run_id.0) .fetch_one(pool) .await .unwrap() } #[tokio::test] async fn stage_and_gate_stages_advances_and_flips_the_symlink_when_gates_are_green() { let (pool, cfg, topo, art, run_id, version, tmp) = stage_fixture(vec![]).await; let deploy_lock = Arc::new(tokio::sync::Mutex::new(())); stage_and_gate( pool.clone(), cfg.clone(), topo, art, crate::events::channel(), run_id, deploy_lock, ) .await .expect("green host pipeline returns Ok"); // Tier advanced to the built version (previous was NULL -> stays NULL). let (current, previous) = tier_versions(&pool, "host").await; assert_eq!(current.as_deref(), Some(version.to_string().as_str())); assert_eq!(previous, None); // Run settled green. let (result, summary) = run_result(&pool, run_id).await; assert_eq!(result, "passed"); assert_eq!(summary, None); // Identity: the build row carries the bundle digest (64 hex) and the // content-addressed dir it was published to (releases/). let (digest, staged_path): (Option, Option) = sqlx::query_as("SELECT bundle_digest, staged_path FROM build_runs WHERE id = ?") .bind(run_id.0) .fetch_one(&pool) .await .unwrap(); let digest = digest.expect("bundle_digest recorded"); let staged_path = staged_path.expect("staged_path recorded"); assert_eq!(digest.len(), 64); let releases = tmp.path().join("release-root").join("releases"); assert_eq!( std::path::Path::new(&staged_path), releases.join(&digest[..16]), "bundle is published content-addressed at releases/" ); // versions.artifact_path points at the primary binary inside that dir, // and it exists on disk. let staged_bin: String = sqlx::query_scalar("SELECT artifact_path FROM versions WHERE version = ?") .bind(version.to_string()) .fetch_one(&pool) .await .unwrap(); let expected_bin = releases.join(&digest[..16]).join("makenotwork"); assert_eq!(staged_bin, expected_bin.to_string_lossy()); assert!( expected_bin.exists(), "staged binary missing at {expected_bin:?}" ); // The bundle carries its MANIFEST (for node-side verification), and the // recorded digest recomputes over the published dir (MANIFEST excluded). assert!( releases.join(&digest[..16]).join("MANIFEST").exists(), "MANIFEST written into the bundle" ); let recomputed = crate::bundle::digest_dir(std::path::Path::new(&staged_path)) .await .unwrap(); assert_eq!( digest, recomputed.full, "recorded digest matches the bundle" ); // The `current` symlink flipped to the content-addressed release. let link = tmp.path().join("release-root").join("current"); let target = std::fs::read_link(&link).expect("current is a symlink"); assert_eq!(target, PathBuf::from(format!("releases/{}", &digest[..16]))); } #[tokio::test] async fn stage_and_gate_marks_the_run_failed_and_does_not_advance_when_a_gate_is_red() { // ManualConfirm with no prior confirmation row blocks deterministically. let (pool, cfg, topo, art, run_id, _version, _tmp) = stage_fixture(vec![Gate::ManualConfirm]).await; let deploy_lock = Arc::new(tokio::sync::Mutex::new(())); // A red gate is a pipeline outcome, not an error: the fn records the // failure and returns Ok so the spawned task settles the run cleanly. stage_and_gate( pool.clone(), cfg, topo, art, crate::events::channel(), run_id, deploy_lock, ) .await .expect("a red gate settles the run, it does not error out"); // Tier did NOT advance — still the seeded NULL/NULL. let (current, previous) = tier_versions(&pool, "host").await; assert_eq!(current, None); assert_eq!(previous, None); // Run settled red with a non-empty summary. let (result, summary) = run_result(&pool, run_id).await; assert_eq!(result, "failed"); assert!( summary.as_deref().is_some_and(|s| !s.is_empty()), "failed run must carry a summary, got {summary:?}" ); } #[test] fn check_build_host_accepts_matching_host() { assert!(check_build_host("fw13", "fw13").is_ok()); } #[test] fn check_build_host_refuses_mismatched_host() { // A daemon misdeployed onto prod (e.g. a Hetzner host) must refuse. let err = check_build_host("alpha-west-1", "fw13") .unwrap_err() .to_string(); assert!(err.contains("refusing to build"), "{err}"); assert!( err.contains("alpha-west-1") && err.contains("fw13"), "{err}" ); } #[test] fn runtime_hostname_reads_a_nonempty_trimmed_name() { let h = runtime_hostname().expect("hostname readable on Linux"); assert!(!h.is_empty()); assert_eq!(h, h.trim(), "must be trimmed"); } #[test] fn tail_does_not_panic_on_multibyte_boundary() { // Each '€' is 3 bytes; a byte cap landing mid-codepoint must not panic. let s = "€".repeat(10); // 30 bytes for max in 1..=30 { let out = tail(s.as_bytes(), max); assert!(out.len() <= max, "max={max} got {} bytes", out.len()); // Result is always valid UTF-8 made only of whole '€'s. assert!(out.chars().all(|c| c == '€'), "max={max}: {out:?}"); } } #[test] fn tail_returns_whole_input_when_under_cap() { assert_eq!(tail(b"hello", 100), "hello"); }