holger-ui 0.1.1

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! Integration test: drive the UI view-model ([`UiData`]) against a **real
//! holger gRPC server** stood up in-process. This exercises the full remote
//! path — `UiData → RemoteHolger (+ bearer interceptor) → tonic → HolgerGrpc →
//! FastRoutes → backend` — i.e. what "run the UI against a holger server" means,
//! minus the desktop window (no display needed, so it runs in CI).
//!
//! Full OIDC token *enforcement* needs mannequin's issuer (`/userinfo`); here the
//! server runs open, so the bearer-token test proves the interceptor is
//! wire-correct (the header is sent and the call still succeeds), not that a bad
//! token is rejected.

use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};

use holger_ui::data::UiData;
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::grpc::holger_proto::{
    admin_service_server::AdminServiceServer, archive_service_server::ArchiveServiceServer,
    repository_service_server::RepositoryServiceServer,
};
use server_lib::grpc::HolgerGrpc;
use server_lib::RemoteHolger;
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait};

/// Emit one functional-status row for a real check into the `nornir test` matrix.
/// Gated behind `--features testmatrix` so the release build strips it entirely.
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
    nornir_testmatrix::functional_status(component, check, ok, detail);
}

/// In-memory repository backend (writable or read-only) for the round-trip.
struct MemRepo {
    name: String,
    writable: bool,
    store: Mutex<HashMap<(Option<String>, String, String), Vec<u8>>>,
}

impl MemRepo {
    fn new(name: &str, writable: bool) -> Self {
        Self {
            name: name.to_string(),
            writable,
            store: Mutex::new(HashMap::new()),
        }
    }
    fn key(id: &ArtifactId) -> (Option<String>, String, String) {
        (id.namespace.clone(), id.name.clone(), id.version.clone())
    }
}

impl RepositoryBackendTrait for MemRepo {
    fn name(&self) -> &str {
        &self.name
    }
    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Rust
    }
    fn is_writable(&self) -> bool {
        self.writable
    }
    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        Ok(self.store.lock().unwrap().get(&Self::key(id)).cloned())
    }
    fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
        self.store.lock().unwrap().insert(Self::key(id), data.to_vec());
        Ok(())
    }
    /// Enumerate the in-memory map into `ArtifactEntry`s — what a real
    /// disk-backed repo's listing yields, but off a `HashMap`. The substring
    /// `name_filter` matches against the artifact name; `limit == 0` means no
    /// cap (the trait's "unlimited" convention).
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        let store = self.store.lock().unwrap();
        let mut out = Vec::new();
        for ((namespace, name, version), bytes) in store.iter() {
            if let Some(f) = name_filter {
                if !name.contains(f) {
                    continue;
                }
            }
            out.push(traits::ArtifactEntry {
                id: ArtifactId {
                    namespace: namespace.clone(),
                    name: name.clone(),
                    version: version.clone(),
                },
                size_bytes: bytes.len() as i64,
                content_type: "application/octet-stream".into(),
            });
            if limit != 0 && out.len() >= limit {
                break;
            }
        }
        Ok(out)
    }
    /// Deterministic fake archive listing — a couple of fixed raw paths,
    /// honouring the optional `prefix` filter. Stands in for a znippy backend's
    /// `archive_files` so the Archive round-trip doesn't need a real archive.
    fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
        let files = vec![
            "crates/serde-1.0.0.crate".to_string(),
            "crates/tokio-1.2.0.crate".to_string(),
        ];
        Ok(match prefix {
            Some(p) => files.into_iter().filter(|f| f.starts_with(p)).collect(),
            None => files,
        })
    }
    /// Deterministic fake archive stats matching `archive_files` above.
    fn archive_info(&self) -> anyhow::Result<traits::ArchiveInfo> {
        Ok(traits::ArchiveInfo {
            file_count: 2,
            total_uncompressed_bytes: 1234,
            archive_path: self.name.clone(),
        })
    }
    fn handle_http2_request(
        &self,
        _method: &str,
        _suburl: &str,
        _body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        Ok((404, Vec::new(), b"not found".to_vec()))
    }
}

fn routes() -> FastRoutes {
    let writable: Arc<dyn RepositoryBackendTrait> = Arc::new(MemRepo::new("rust-dev", true));
    let readonly: Arc<dyn RepositoryBackendTrait> = Arc::new(MemRepo::new("rust-prod", false));
    FastRoutes::new(vec![
        ("rust-dev".to_string(), writable),
        ("rust-prod".to_string(), readonly),
    ])
}

/// Stand up a real holger gRPC server on an ephemeral port on its own runtime
/// thread; return the bound address.
fn spawn_holger() -> SocketAddr {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let rt = tokio::runtime::Runtime::new().expect("server runtime");
        rt.block_on(async move {
            let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
                .await
                .expect("bind ephemeral port");
            tx.send(listener.local_addr().expect("local_addr"))
                .expect("send addr");
            let state = Arc::new(HolgerGrpc::new(routes())); // default auth = open
            tonic::transport::Server::builder()
                .add_service(RepositoryServiceServer::new(state.clone()))
                .add_service(ArchiveServiceServer::new(state.clone()))
                .add_service(AdminServiceServer::new(state.clone()))
                .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
                .await
                .expect("serve");
        });
    });
    rx.recv().expect("server address")
}

/// Build a data layer whose runtime owns the RemoteHolger channel.
fn remote_ui(addr: SocketAddr, token: Option<&str>) -> UiData {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    let endpoint = format!("http://{addr}");
    let remote = match token {
        Some(t) => rt.block_on(RemoteHolger::connect_with_token(endpoint, t)),
        None => rt.block_on(RemoteHolger::connect(endpoint)),
    }
    .expect("connect to in-process holger");
    UiData::with_runtime(Arc::new(remote), rt)
}

#[test]
fn ui_data_round_trips_over_real_grpc() {
    let addr = spawn_holger();
    let mut ui = remote_ui(addr, None);

    ui.refresh_status();
    assert!(ui.status.loaded, "status error: {:?}", ui.status.error);
    assert_eq!(ui.status.status, "ok");
    assert!(!ui.status.version.is_empty());

    ui.refresh_repos();
    assert_eq!(ui.repos.repos.len(), 2);
    let dev = ui
        .repos
        .repos
        .iter()
        .position(|r| r.name == "rust-dev")
        .expect("rust-dev");
    ui.select_repo(dev);
    assert!(ui.repos.upload_enabled());

    // Put → fetch round-trip over the wire.
    let id = ArtifactId {
        namespace: None,
        name: "serde".into(),
        version: "1.0.0".into(),
    };
    ui.put_artifact("rust-dev", &id, b"CRATE_BYTES")
        .expect("put over grpc");
    ui.fetch_artifact("rust-dev", id.clone());
    assert!(ui.artifact.found);
    assert_eq!(ui.artifact.size_bytes, b"CRATE_BYTES".len() as u64);
    assert_eq!(ui.artifact.data, b"CRATE_BYTES");

    // Browse the writable repo over the wire: this exercises the full listing
    // path UI → RemoteHolger → gRPC → HolgerGrpc::list_artifacts → backend.list.
    // The artifact we just put must show up with the right id and byte size.
    ui.refresh_browse("rust-dev", None);
    assert!(ui.browse.error.is_none(), "browse error: {:?}", ui.browse.error);
    let row = ui
        .browse
        .entries
        .iter()
        .find(|r| r.name == "serde" && r.version == "1.0.0")
        .expect("put artifact present in browse listing");
    assert_eq!(row.size_bytes, b"CRATE_BYTES".len() as i64);
    #[cfg_attr(not(feature = "testmatrix"), allow(unused_variables))]
    let browsed_size = row.size_bytes;

    // Archive browse over the wire: exercises the full path UI → RemoteHolger →
    // gRPC → HolgerGrpc::list_archive_files / archive_info → backend. The MemRepo
    // serves a deterministic fake archive.
    ui.refresh_archive("rust-dev", None);
    assert!(ui.archive.error.is_none(), "archive error: {:?}", ui.archive.error);
    assert_eq!(ui.archive.repository, "rust-dev");
    assert_eq!(ui.archive.file_count, 2);
    assert_eq!(ui.archive.total_uncompressed_bytes, 1234);
    assert_eq!(ui.archive.archive_path, "rust-dev");
    assert_eq!(ui.archive.files.len(), 2);
    assert!(ui
        .archive
        .files
        .iter()
        .any(|f| f == "crates/serde-1.0.0.crate"));

    // Read-only repo → PERMISSION_DENIED surfaced as an error.
    let err = ui.put_artifact("rust-prod", &id, b"x").unwrap_err();
    let msg = err.to_string().to_lowercase();
    assert!(
        msg.contains("read-only") || msg.contains("permission"),
        "unexpected error: {err}"
    );

    // NOT_FOUND → found=false, no error.
    let missing = ArtifactId {
        namespace: None,
        name: "does-not-exist".into(),
        version: "0.0.0".into(),
    };
    ui.fetch_artifact("rust-dev", missing);
    assert!(!ui.artifact.found);
    assert!(ui.artifact.error.is_none());

    #[cfg(feature = "testmatrix")]
    fstatus(
        "holger-ui",
        "remote_grpc_round_trip",
        ui.status.status == "ok"
            && ui.repos.repos.len() == 2
            && browsed_size == b"CRATE_BYTES".len() as i64
            && ui.archive.file_count == 2
            && (msg.contains("read-only") || msg.contains("permission"))
            && !ui.artifact.found,
        &format!(
            "over gRPC: status={} {} repos, put/fetch {} bytes, archive {} files, PERMISSION_DENIED mapped",
            ui.status.status,
            ui.repos.repos.len(),
            browsed_size,
            ui.archive.file_count,
        ),
    );
}

#[test]
fn bearer_token_path_is_wire_correct() {
    let addr = spawn_holger();
    let mut ui = remote_ui(addr, Some("test-token-123"));
    ui.refresh_status();
    assert!(ui.status.loaded, "status error: {:?}", ui.status.error);
    ui.refresh_repos();
    assert_eq!(ui.repos.repos.len(), 2);

    #[cfg(feature = "testmatrix")]
    fstatus(
        "holger-ui",
        "bearer_token_wire_correct",
        ui.status.loaded && ui.repos.repos.len() == 2,
        &format!("bearer interceptor: status loaded={} repos={}", ui.status.loaded, ui.repos.repos.len()),
    );
}