server/lib/tests/migration_door_e2e.rs
//! **The migration door, over real HTTP, from the client's side of the wire.**
//!
//! `grpc::migration`'s unit tests prove the mapping from a plan row onto the
//! wire. They cannot prove that a client RECEIVES it: a field can be mapped and
//! then dropped by codegen, a gate can refuse before the body is read, a whole
//! service can be left out of the route table by a `cfg`. The console's Migrate
//! page renders exactly two things it cannot compute for itself — the
//! `disposition` of every repository and the `declaration` a `create` row is
//! waiting on — so those are what this asserts, on a `DiscoverMigrationResponse`
//! that came back over a real TCP connection from a real holger, against a real
//! HTTP Nexus.
//!
//! ## The two facts the page is built on
//!
//! 1. A repository this holger DOES have comes back `existing` and carries no
//! declaration. That is the only row the page lets an operator select.
//! 2. A repository it does NOT have comes back `create` **with the RON to
//! paste**. holger cannot create it while it runs — `FastRoutes` is built
//! from the config at boot and is immutable for the life of the process — so
//! the page shows the declaration instead of a button. If this field ever
//! came back empty the page would silently offer nothing, which is the exact
//! failure it exists to prevent.
//! 3. A name that exists here in ANOTHER format is a collision: the plan is
//! refused as a whole and its report must never say it would run.
//!
//! ## How to see this go RED
//!
//! Make `plan_row_to_proto` fill `declaration` for every disposition instead of
//! `Create` alone: `an_existing_repository_needs_no_declaration` fails. Drop the
//! field entirely and the `create` assertions fail.
//!
//! The Nexus here is an axum app answering the two REST v1 endpoints the
//! connector calls. It is a fixture, not a product surface — `holger-mannequin`
//! is the real mock and it is a GUI binary crate; linking it into this crate's
//! dev graph to serve two JSON routes would cost more than it explains.
use std::io::Write;
use std::net::SocketAddr;
use traits::test_support::hold_port;
use axum::extract::Query;
use axum::routing::get;
use axum::{Json, Router};
use holger_server_lib::grpc::holger_proto::migration_service_client::MigrationServiceClient;
use holger_server_lib::grpc::holger_proto::{
migration_source, DiscoverMigrationRequest, MigrationSource,
};
use holger_server_lib::{read_ron_config, wire_holger, Holger};
// ─── The source: a Nexus, over HTTP ──────────────────────────────────────────
#[derive(serde::Deserialize)]
struct AssetQuery {
repository: String,
}
/// The registry listing. `prod_format` is what the SOURCE calls `prod`: pass
/// what this holger has it as and the row is `existing`, pass anything else and
/// it is a collision -- one name, two formats.
fn repositories(prod_format: &str) -> Json<serde_json::Value> {
// Three hosted repositories. `prod` is the one the holger under test
// declares; the other two are the ones it cannot create.
Json(serde_json::json!([
{ "name": "prod", "format": prod_format, "type": "hosted" },
{ "name": "upload", "format": "raw", "type": "hosted" },
{ "name": "proxy-only", "format": "raw", "type": "hosted" },
]))
}
async fn assets(Query(q): Query<AssetQuery>) -> Json<serde_json::Value> {
let items = match q.repository.as_str() {
"prod" => serde_json::json!([
{ "path": "a/one.txt", "downloadUrl": "http://x/one", "fileSize": 10 },
{ "path": "a/two.txt", "downloadUrl": "http://x/two", "fileSize": 20 },
]),
"upload" => serde_json::json!([
{ "path": "b/three.txt", "downloadUrl": "http://x/three", "fileSize": 30 },
]),
_ => serde_json::json!([]),
};
Json(serde_json::json!({ "items": items, "continuationToken": null }))
}
/// Serve the mock on its own task and return its base URL.
async fn start_nexus(prod_format: &str) -> String {
let port = hold_port();
let listing = repositories(prod_format);
let app = Router::new()
.route(
"/service/rest/v1/repositories",
get(move || {
let listing = listing.clone();
async move { listing }
}),
)
.route("/service/rest/v1/assets", get(assets));
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
format!("http://127.0.0.1:{port}")
}
// ─── The destination: a real holger ──────────────────────────────────────────
fn config(repo_format: &str, data: &str, grpc: u16, http: u16) -> String {
format!(
r#"(
operation_mode: ReadWrite,
repositories: [
(
ron_name: "prod",
ron_repo_type: "{repo_format}",
ron_upstreams: [],
ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
ron_data_dir: Some("{data}"),
),
],
exposed_endpoints: [
(ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
],
storage_endpoints: [
(ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}"),
],
)"#
)
}
struct Running {
_data: tempfile::TempDir,
_cfg: tempfile::NamedTempFile,
grpc: String,
}
fn start_holger(repo_format: &str) -> Running {
let data = tempfile::tempdir().unwrap();
let grpc = hold_port();
let http = hold_port();
let ron = config(repo_format, &data.path().display().to_string(), grpc, http);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
holger.start().unwrap();
Running {
_data: data,
_cfg: cfg,
grpc: format!("http://127.0.0.1:{grpc}"),
}
}
async fn dial(url: &str) -> MigrationServiceClient<tonic::transport::Channel> {
for _ in 0..200 {
if let Ok(c) = MigrationServiceClient::connect(url.to_string()).await {
return c;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("the migration door never came up at {url}");
}
fn source(base_url: &str) -> MigrationSource {
MigrationSource {
kind: migration_source::Kind::Nexus as i32,
base_url: base_url.to_string(),
username: String::new(),
password: String::new(),
repository: String::new(),
}
}
// ─── The assertions ──────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn discover_hands_the_console_a_declaration_for_every_repository_it_cannot_create() {
let nexus = start_nexus("raw").await;
let holger = start_holger("raw");
let mut client = dial(&holger.grpc).await;
let plan = client
.discover(DiscoverMigrationRequest {
source: Some(source(&nexus)),
})
.await
.expect("Discover must answer")
.into_inner();
assert_eq!(
plan.repos.len(),
3,
"every source repository is in the plan"
);
assert!(plan.would_run, "nothing collides, so the plan would run");
assert!(plan.refusals.is_empty());
assert_eq!(plan.total_assets, 3, "2 in prod + 1 in upload");
let by_name = |name: &str| {
plan.repos
.iter()
.find(|r| r.source_repo == name)
.unwrap_or_else(|| panic!("{name} missing from the plan"))
.clone()
};
// 1 — the one the page may offer.
let prod = by_name("prod");
assert_eq!(prod.disposition, "existing");
assert_eq!(prod.target_format, "raw");
assert_eq!(prod.assets, 2);
assert!(
prod.declaration.is_empty(),
"an existing repository needs no declaration: {}",
prod.declaration
);
// 2 — the ones it must NOT offer, each carrying the RON to paste.
for name in ["upload", "proxy-only"] {
let row = by_name(name);
assert_eq!(row.disposition, "create", "{name}");
assert!(
row.refusal_reason.is_empty(),
"{name} does not refuse — it waits"
);
assert!(
row.declaration.contains(&format!("ron_name: \"{name}\"")),
"{name} came back with no declaration to paste: {:?}",
row.declaration
);
assert!(
row.declaration.contains("ron_repo_type: \"raw\""),
"the declaration must name holger's own format word: {:?}",
row.declaration
);
}
// 3 — the shared preview text, the same renderer the CLI dry run prints.
assert!(plan.report.contains("MIGRATION DRY RUN"), "{}", plan.report);
assert!(plan.report.contains("WOULD CREATE (2)"), "{}", plan.report);
assert!(plan.report.contains("would RUN"), "{}", plan.report);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_collision_comes_back_refused_and_its_report_never_invites_execution() {
// The source calls `prod` an npm repository; this holger has it as raw.
let nexus = start_nexus("npm").await;
let holger = start_holger("raw");
let mut client = dial(&holger.grpc).await;
let plan = client
.discover(DiscoverMigrationRequest {
source: Some(source(&nexus)),
})
.await
.expect("Discover must answer even when the plan refuses")
.into_inner();
assert!(!plan.would_run, "a collision must never read as runnable");
assert!(
!plan.refusals.is_empty(),
"the refusal must reach the console"
);
let prod = plan
.repos
.iter()
.find(|r| r.source_repo == "prod")
.expect("prod is in the plan");
assert_eq!(prod.disposition, "collision");
assert!(
prod.refusal_reason.contains("npm"),
"{}",
prod.refusal_reason
);
assert!(
prod.declaration.is_empty(),
"a collision is not fixed by declaring it again"
);
assert!(plan.report.contains("REFUSED"), "{}", plan.report);
assert!(
!plan.report.contains("would RUN"),
"a refused plan must never invite execution: {}",
plan.report
);
}