Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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
    );
}