From 440c42c6a2dde2472813b0661631fa52ee5083f1 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sun, 8 Feb 2026 21:56:03 +0000 Subject: Add smoke test for go and zig --- .github/workflows/build.yml | 16 +- Cargo.lock | 48 ++++- Cargo.toml | 2 +- Makefile | 8 + crates/zorian/src/config.rs | 26 ++- deny.toml | 1 + tests/smoke/Cargo.toml | 18 ++ tests/smoke/run-local.sh | 77 +++++++ tests/smoke/src/main.rs | 403 ++++++++++++++++++++++++++++++++++++ 9 files changed, 585 insertions(+), 14 deletions(-) create mode 100644 tests/smoke/Cargo.toml create mode 100755 tests/smoke/run-local.sh create mode 100644 tests/smoke/src/main.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d1c4b3..7e70498 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -106,10 +106,24 @@ jobs: name: packages-${{ matrix.arch }} path: dist/* + smoke: + name: Smoke tests + runs-on: ubuntu-24.04 + needs: [build] + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + + - name: Run smoke tests against local instance + run: ./tests/smoke/run-local.sh + publish: name: Publish dev build runs-on: ubuntu-latest - needs: [build] + needs: [build, smoke] if: github.ref == 'refs/heads/main' permissions: contents: write diff --git a/Cargo.lock b/Cargo.lock index e608820..ee96265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,7 +1575,7 @@ dependencies = [ "bytes", "http", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -1590,7 +1590,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tonic", @@ -2065,6 +2065,41 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2412,6 +2447,15 @@ dependencies = [ "serde", ] +[[package]] +name = "smoke" +version = "0.1.0" +dependencies = [ + "hex", + "reqwest 0.13.2", + "sha2", +] + [[package]] name = "socket2" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 19d2306..d073fdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [workspace] -members = ["crates/*", "xtask"] +members = ["crates/*", "tests/smoke", "xtask"] resolver = "3" [workspace.package] diff --git a/Makefile b/Makefile index 87b77ab..2a7b558 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,14 @@ test: cargo build --release cargo test --all-features --release --locked +.PHONY: smoke +smoke: + cargo run -p smoke + +.PHONY: smoke-local +smoke-local: + ./tests/smoke/run-local.sh + .PHONY: coverage coverage: mkdir -p target/coverage/ diff --git a/crates/zorian/src/config.rs b/crates/zorian/src/config.rs index 5719d22..79da442 100644 --- a/crates/zorian/src/config.rs +++ b/crates/zorian/src/config.rs @@ -227,6 +227,7 @@ pub struct BackendsConfig { } #[derive(Debug, Deserialize)] +#[serde(default)] pub struct ConfigService { appname: String, dirname: PathBuf, @@ -236,6 +237,19 @@ pub struct ConfigService { backends: BackendsConfig, } +impl Default for ConfigService { + fn default() -> Self { + Self { + appname: "zorian".to_string(), + dirname: PathBuf::from("./.zorian-state"), + listen: vec![ListenerConfig::default()], + server: ServerConfig::default(), + telemetry: TelemetryConfig::default(), + backends: BackendsConfig::default(), + } + } +} + impl ConfigService { pub fn load(config_path: Option) -> Result { let config = match config_path { @@ -243,19 +257,11 @@ impl ConfigService { info!("use config file from {}", path.to_str().unwrap()); let content = fs::read_to_string(path)?; - let config: Self = toml::from_str(&content)?; - config + toml::from_str(&content)? } None => { info!("configuration file path not provided"); - Self { - appname: "zorian".to_string(), - dirname: PathBuf::from("./.zorian-state"), - server: ServerConfig::default(), - listen: vec![ListenerConfig::default()], - telemetry: TelemetryConfig::default(), - backends: BackendsConfig::default(), - } + Self::default() } }; diff --git a/deny.toml b/deny.toml index 563ab6a..f9d0ba0 100644 --- a/deny.toml +++ b/deny.toml @@ -26,6 +26,7 @@ exceptions = [ { crate = "base", allow = ["AGPL-3.0-or-later"] }, { crate = "repos", allow = ["AGPL-3.0-or-later"] }, { crate = "zorian", allow = ["AGPL-3.0-or-later"] }, + { crate = "smoke", allow = ["AGPL-3.0-or-later"] }, ] unused-allowed-license = "allow" diff --git a/tests/smoke/Cargo.toml b/tests/smoke/Cargo.toml new file mode 100644 index 0000000..c3d953c --- /dev/null +++ b/tests/smoke/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +[package] +name = "smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "smoke" +path = "src/main.rs" + +[dependencies] +hex.workspace = true +reqwest = { version = "0.13", features = ["blocking", "native-tls"], default-features = false } +sha2 = "0.10" diff --git a/tests/smoke/run-local.sh b/tests/smoke/run-local.sh new file mode 100755 index 0000000..4117271 --- /dev/null +++ b/tests/smoke/run-local.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Builds zorian, starts it in a temporary directory, runs smoke tests, cleans up. +# +# Usage: +# ./tests/smoke/run-local.sh # default port 2025 +# ZORIAN_PORT=9999 ./tests/smoke/run-local.sh + +set -euo pipefail + +PORT="${ZORIAN_PORT:-2025}" +BASE_URL="http://127.0.0.1:${PORT}" + +TMPDIR="$(mktemp -d)" +LOGFILE="$TMPDIR/zorian.log" +trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$TMPDIR"' EXIT + +# Build +echo "Building zorian..." +cargo build --release -p zorian + +# Write minimal config +cat > "$TMPDIR/zorian.toml" <"$LOGFILE" 2>&1 & +PID=$! + +# Wait for index to load (log shows "index refreshed" for each backend) +echo "Waiting for index to load..." +for i in $(seq 1 120); do + if grep -q "index refreshed" "$LOGFILE" 2>/dev/null; then + echo "Index loaded after ${i}s" + break + fi + if ! kill -0 "$PID" 2>/dev/null; then + echo "zorian exited unexpectedly. Server log:" + cat "$LOGFILE" + exit 1 + fi + sleep 1 +done + +if ! grep -q "index refreshed" "$LOGFILE" 2>/dev/null; then + echo "Timed out waiting for index. Server log:" + cat "$LOGFILE" + exit 1 +fi + +# Run smoke tests +ZORIAN_URL="${BASE_URL}" cargo run -p smoke diff --git a/tests/smoke/src/main.rs b/tests/smoke/src/main.rs new file mode 100644 index 0000000..93a914b --- /dev/null +++ b/tests/smoke/src/main.rs @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Black-box smoke tests for Zorian. +// +// Downloads archives from Zorian and upstream, compares sha256 hashes, +// verifies that minisig signatures (Zig) and sha256 checksums (Go) match upstream. +// +// Usage: +// ZORIAN_URL=https://pkg.earth cargo run -p smoke + +use sha2::{Digest, Sha256}; + +const GO_FILES: &[&str] = &[ + "go1.23.0.linux-amd64.tar.gz", + "go1.23.0.darwin-arm64.tar.gz", + "go1.21.0.windows-amd64.zip", +]; + +struct ZigTestFile { + file: &'static str, + version: &'static str, +} + +const ZIG_FILES: &[ZigTestFile] = &[ + // 0.15.2 (new tarball name format, some new targets) + ZigTestFile { + file: "zig-0.15.2.tar.xz", + version: "0.15.2", + }, + ZigTestFile { + file: "zig-x86_64-windows-0.15.2.zip", + version: "0.15.2", + }, + ZigTestFile { + file: "zig-aarch64-macos-0.15.2.tar.xz", + version: "0.15.2", + }, + ZigTestFile { + file: "zig-aarch64-netbsd-0.15.2.tar.xz", + version: "0.15.2", + }, + ZigTestFile { + file: "zig-powerpc64le-freebsd-0.15.2.tar.xz", + version: "0.15.2", + }, + // Every 'zig-xxx-linux-0.14.1.tar.xz' (same order as on the website) + ZigTestFile { + file: "zig-x86_64-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-aarch64-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-armv7a-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-riscv64-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-powerpc64le-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-x86-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-loongarch64-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + ZigTestFile { + file: "zig-s390x-linux-0.14.1.tar.xz", + version: "0.14.1", + }, + // 0.10.1 (last stage1 release) + ZigTestFile { + file: "zig-0.10.1.tar.xz", + version: "0.10.1", + }, + ZigTestFile { + file: "zig-bootstrap-0.10.1.tar.xz", + version: "0.10.1", + }, + ZigTestFile { + file: "zig-linux-i386-0.10.1.tar.xz", + version: "0.10.1", + }, + ZigTestFile { + file: "zig-macos-aarch64-0.10.1.tar.xz", + version: "0.10.1", + }, + ZigTestFile { + file: "zig-windows-x86_64-0.10.1.zip", + version: "0.10.1", + }, + // 0.7.1 (oldest supported patch release) + ZigTestFile { + file: "zig-0.7.1.tar.xz", + version: "0.7.1", + }, + ZigTestFile { + file: "zig-linux-x86_64-0.7.1.tar.xz", + version: "0.7.1", + }, + // 0.6.0 (oldest supported version) + ZigTestFile { + file: "zig-0.6.0.tar.xz", + version: "0.6.0", + }, + ZigTestFile { + file: "zig-linux-x86_64-0.6.0.tar.xz", + version: "0.6.0", + }, + // 0.1.1 (first release) + ZigTestFile { + file: "zig-win64-0.1.1.zip", + version: "0.1.1", + }, +]; + +struct Runner { + passed: u32, + failed: u32, + client: reqwest::blocking::Client, +} + +impl Runner { + fn new() -> Self { + Self { + passed: 0, + failed: 0, + client: reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(600)) + .build() + .expect("failed to create HTTP client"), + } + } + + fn ok(&mut self, name: &str) { + self.passed += 1; + println!(" \x1b[32mok\x1b[0m {name}"); + } + + fn fail(&mut self, name: &str, reason: &str) { + self.failed += 1; + println!(" \x1b[31mFAIL\x1b[0m {name}"); + println!(" {reason}"); + } + + fn fetch_bytes(&self, url: &str) -> Result, String> { + let resp = self.client.get(url).send().map_err(|e| format!("{e}"))?; + if !resp.status().is_success() { + return Err(format!("{} for {url}", resp.status())); + } + resp.bytes().map(|b| b.to_vec()).map_err(|e| format!("{e}")) + } + + fn fetch_status(&self, url: &str) -> Result { + let resp = self.client.get(url).send().map_err(|e| format!("{e}"))?; + Ok(resp.status().as_u16()) + } + + fn fetch_text(&self, url: &str) -> Result<(u16, String, String), String> { + let resp = self.client.get(url).send().map_err(|e| format!("{e}"))?; + let status = resp.status().as_u16(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = resp.text().map_err(|e| format!("{e}"))?; + Ok((status, content_type, body)) + } +} + +fn sha256hex(data: &[u8]) -> String { + hex::encode(Sha256::digest(data)) +} + +fn zig_upstream_url(file: &str, version: &str) -> String { + format!("https://ziglang.org/download/{version}/{file}") +} + +fn test_web(r: &mut Runner, base_url: &str) { + println!("\n--- Web ---"); + + { + let name = "GET / returns html"; + match r.fetch_text(&format!("{base_url}/")) { + Ok((status, ct, body)) => { + if status != 200 { + r.fail(name, &format!("status {status}, expected 200")); + } else if !ct.contains("text/html") { + r.fail(name, &format!("content-type: {ct}")); + } else if !body.contains("Zorian") { + r.fail(name, "body missing \"Zorian\""); + } else { + r.ok(name); + } + } + Err(e) => r.fail(name, &e), + } + } + + { + let name = "GET /base.css returns css"; + match r.fetch_text(&format!("{base_url}/base.css")) { + Ok((status, ct, body)) => { + if status != 200 { + r.fail(name, &format!("status {status}")); + } else if !ct.contains("text/css") { + r.fail(name, &format!("content-type: {ct}")); + } else if body.is_empty() { + r.fail(name, "empty body"); + } else { + r.ok(name); + } + } + Err(e) => r.fail(name, &e), + } + } + + { + let name = "GET /favicon.ico returns icon"; + match r.fetch_status(&format!("{base_url}/favicon.ico")) { + Ok(200) => r.ok(name), + Ok(s) => r.fail(name, &format!("status {s}")), + Err(e) => r.fail(name, &e), + } + } + + { + let name = "GET /about/licenses returns html"; + match r.fetch_text(&format!("{base_url}/about/licenses")) { + Ok((status, ct, body)) => { + if status != 200 { + r.fail(name, &format!("status {status}")); + } else if !ct.contains("text/html") { + r.fail(name, &format!("content-type: {ct}")); + } else if !body.contains("AGPL") { + r.fail(name, "body missing \"AGPL\""); + } else { + r.ok(name); + } + } + Err(e) => r.fail(name, &e), + } + } + + { + let name = "GET /does-not-exist returns 404"; + match r.fetch_status(&format!("{base_url}/does-not-exist.xyz")) { + Ok(404) => r.ok(name), + Ok(s) => r.fail(name, &format!("status {s}, expected 404")), + Err(e) => r.fail(name, &e), + } + } +} + +fn test_go(r: &mut Runner, base_url: &str) { + println!("\n--- Go ---"); + + for file in GO_FILES { + // Archive: compare sha256 with upstream + { + let name = format!("go: {file} matches upstream"); + match ( + r.fetch_bytes(&format!("{base_url}/go/{file}")), + r.fetch_bytes(&format!("https://go.dev/dl/{file}")), + ) { + (Ok(zorian), Ok(upstream)) => { + let zh = sha256hex(&zorian); + let uh = sha256hex(&upstream); + if zh != uh { + r.fail(&name, &format!("zorian={zh} upstream={uh}")); + } else { + r.ok(&name); + } + } + (Err(e), _) | (_, Err(e)) => r.fail(&name, &e), + } + } + + // Checksum: compare .sha256 endpoint with computed hash + { + let name = format!("go: {file}.sha256 matches content"); + match ( + r.fetch_bytes(&format!("{base_url}/go/{file}")), + r.fetch_text(&format!("{base_url}/go/{file}.sha256")), + ) { + (Ok(archive), Ok((200, _, checksum_body))) => { + let computed = sha256hex(&archive); + let expected = checksum_body.trim(); + if computed != expected { + r.fail(&name, &format!("computed={computed} endpoint={expected}")); + } else { + r.ok(&name); + } + } + (Ok(_), Ok((s, _, _))) => { + r.fail(&name, &format!("sha256 endpoint returned {s}")); + } + (Err(e), _) | (_, Err(e)) => r.fail(&name, &e), + } + } + } + + { + let name = "go: nonexistent version returns 404"; + match r.fetch_status(&format!("{base_url}/go/go99.99.99.linux-amd64.tar.gz")) { + Ok(404) => r.ok(name), + Ok(s) => r.fail(name, &format!("status {s}, expected 404")), + Err(e) => r.fail(name, &e), + } + } +} + +fn test_zig(r: &mut Runner, base_url: &str) { + println!("\n--- Zig ---"); + + for entry in ZIG_FILES { + // Archive: compare sha256 with upstream + { + let name = format!("zig: {} matches upstream", entry.file); + match ( + r.fetch_bytes(&format!("{base_url}/zig/{}", entry.file)), + r.fetch_bytes(&zig_upstream_url(entry.file, entry.version)), + ) { + (Ok(zorian), Ok(upstream)) => { + let zh = sha256hex(&zorian); + let uh = sha256hex(&upstream); + if zh != uh { + r.fail(&name, &format!("zorian={zh} upstream={uh}")); + } else { + r.ok(&name); + } + } + (Err(e), _) | (_, Err(e)) => r.fail(&name, &e), + } + } + + // Signature: compare .minisig with upstream + { + let name = format!("zig: {}.minisig matches upstream", entry.file); + let zorian_url = format!("{base_url}/zig/{}.minisig", entry.file); + let upstream_url = format!("{}.minisig", zig_upstream_url(entry.file, entry.version)); + match (r.fetch_bytes(&zorian_url), r.fetch_bytes(&upstream_url)) { + (Ok(zorian), Ok(upstream)) => { + if zorian != upstream { + r.fail(&name, "minisig content differs from upstream"); + } else { + r.ok(&name); + } + } + (Err(e), _) | (_, Err(e)) => r.fail(&name, &e), + } + } + } + + { + let name = "zig: nonexistent version returns 404"; + match r.fetch_status(&format!("{base_url}/zig/zig-x86_64-linux-99.99.99.tar.xz")) { + Ok(404) => r.ok(name), + Ok(s) => r.fail(name, &format!("status {s}, expected 404")), + Err(e) => r.fail(name, &e), + } + } +} + +fn main() { + let base_url = match std::env::var("ZORIAN_URL") { + Ok(url) => url, + Err(_) => { + eprintln!("Error: ZORIAN_URL environment variable is not set."); + eprintln!("Usage: ZORIAN_URL=https://pkg.earth cargo run -p smoke"); + std::process::exit(1); + } + }; + + let base_url = base_url.trim_end_matches('/'); + println!("Smoke tests against {base_url}"); + + let mut runner = Runner::new(); + + test_web(&mut runner, base_url); + test_go(&mut runner, base_url); + test_zig(&mut runner, base_url); + + println!( + "\n--- Results: {} passed, {} failed ---", + runner.passed, runner.failed + ); + + if runner.failed > 0 { + std::process::exit(1); + } +} -- Gilti