//! Tests for `script/ci-publish-artifact` and `.gitea/workflows/release-artifact.yml` //! (story 1189). Exercises the publish script end-to-end as a subprocess against a //! minimal local HTTP stub rather than a live channel host — this crate has no //! production code path that talks to the dev channel itself. use std::collections::{HashMap, VecDeque}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::process::Command; use std::sync::{Arc, Mutex}; use std::thread; /// One request as observed by the stub: method, path, and the two headers /// the publish script is required to send. struct ObservedRequest { method: String, path: String, authorization: Option, git_hash: Option, } fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") } fn script_path() -> PathBuf { repo_root().join("script/ci-publish-artifact") } /// Read one HTTP/1.1 request off `stream`: request line, headers (lower-cased /// keys), and body (read exactly `Content-Length` bytes, defaulting to none). fn read_request(stream: &mut TcpStream) -> (String, String, HashMap, Vec) { let mut header_bytes = Vec::new(); let mut byte = [0u8; 1]; loop { stream.read_exact(&mut byte).expect("read request byte"); header_bytes.push(byte[0]); if header_bytes.ends_with(b"\r\n\r\n") { break; } } let header_text = String::from_utf8_lossy(&header_bytes); let mut lines = header_text.lines(); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or("").to_string(); let path = parts.next().unwrap_or("").to_string(); let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { headers.insert(k.trim().to_lowercase(), v.trim().to_string()); } } let content_length: usize = headers .get("content-length") .and_then(|v| v.parse().ok()) .unwrap_or(0); let mut body = vec![0u8; content_length]; if content_length > 0 { stream.read_exact(&mut body).expect("read request body"); } (method, path, headers, body) } fn write_response(stream: &mut TcpStream, status: u16, body: &str) { let reason = match status { 200 => "OK", 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error", _ => "Unknown", }; let response = format!( "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); } /// Spawn a background thread serving `responses` in order, one per accepted /// connection; once exhausted, further requests get a 500 fallback (a test /// bug, not an expected script behaviour). Returns the port and a log of /// every request observed, in arrival order. fn spawn_stub(responses: Vec<(u16, &'static str)>) -> (u16, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener"); let port = listener.local_addr().expect("stub local addr").port(); let queue = Arc::new(Mutex::new(responses.into_iter().collect::>())); let log: Arc>> = Arc::new(Mutex::new(Vec::new())); let log_writer = Arc::clone(&log); thread::spawn(move || { for stream in listener.incoming() { let Ok(mut stream) = stream else { break }; let (method, path, headers, _body) = read_request(&mut stream); log_writer.lock().unwrap().push(ObservedRequest { method, path, authorization: headers.get("authorization").cloned(), git_hash: headers.get("x-git-hash").cloned(), }); let (status, body) = queue .lock() .unwrap() .pop_front() .unwrap_or((500, "no more stub responses queued")); write_response(&mut stream, status, body); } }); (port, log) } struct RunResult { success: bool, stderr: String, } fn run_publish_script(port: u16, git_hash: &str) -> RunResult { let dir = tempfile::tempdir().expect("tempdir"); let artifact_path = dir.path().join("huskies-linux-arm64"); std::fs::write(&artifact_path, b"fake sled binary").expect("write fake artifact"); let output = Command::new("bash") .arg(script_path()) .arg(&artifact_path) .arg(git_hash) .env("HUSKIES_CHANNEL_URL", format!("http://127.0.0.1:{port}")) .env("HUSKIES_CHANNEL_TOKEN", "test-token") .env("HUSKIES_CI_PUBLISH_BACKOFF_SECS", "1") .output() .expect("run script/ci-publish-artifact"); RunResult { success: output.status.success(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), } } // ── Script behaviour ───────────────────────────────────────────────────── #[test] fn publish_succeeds_and_verifies_manifest() { let (port, log) = spawn_stub(vec![ (200, r#"{"status":"ok"}"#), (200, r#"{"git_hash":"abc1234"}"#), ]); let result = run_publish_script(port, "abc1234"); assert!(result.success, "expected success: {}", result.stderr); let requests = log.lock().unwrap(); assert_eq!(requests.len(), 2, "expected one upload + one manifest GET"); assert_eq!(requests[0].method, "POST"); assert_eq!( requests[0].authorization.as_deref(), Some("Bearer test-token") ); assert_eq!(requests[0].git_hash.as_deref(), Some("abc1234")); assert_eq!(requests[1].method, "GET"); assert_eq!(requests[1].path, "/manifest.json"); } #[test] fn publish_fails_immediately_on_auth_failure_without_retry() { let (port, log) = spawn_stub(vec![(401, r#"{"error":"bad token"}"#)]); let result = run_publish_script(port, "abc1234"); assert!(!result.success, "auth failure must not succeed"); assert!( result.stderr.contains("authentication failed"), "stderr should explain the auth failure: {}", result.stderr ); let requests = log.lock().unwrap(); assert_eq!( requests.len(), 1, "a 401 must not be retried, and must not reach the manifest check" ); } #[test] fn publish_retries_transient_failure_then_succeeds() { let (port, log) = spawn_stub(vec![ (500, "boom"), (200, r#"{"status":"ok"}"#), (200, r#"{"git_hash":"abc1234"}"#), ]); let result = run_publish_script(port, "abc1234"); assert!( result.success, "expected eventual success: {}", result.stderr ); let requests = log.lock().unwrap(); assert_eq!( requests.len(), 3, "expected the failed upload, the retried upload, and the manifest GET" ); assert_eq!(requests[0].method, "POST"); assert_eq!(requests[1].method, "POST"); assert_eq!(requests[2].method, "GET"); } #[test] fn publish_fails_on_manifest_mismatch() { let (port, log) = spawn_stub(vec![ (200, r#"{"status":"ok"}"#), (200, r#"{"git_hash":"deadbee"}"#), (200, r#"{"git_hash":"deadbee"}"#), (200, r#"{"git_hash":"deadbee"}"#), ]); let result = run_publish_script(port, "abc1234"); assert!(!result.success, "manifest mismatch must fail"); assert!( result.stderr.contains("manifest mismatch"), "stderr should explain the mismatch: {}", result.stderr ); let requests = log.lock().unwrap(); assert_eq!( requests.len(), 4, "expected the upload plus 3 manifest-check attempts" ); } // ── Workflow yaml structure ────────────────────────────────────────────── #[test] fn workflow_yaml_triggers_on_master_push_and_targets_arm64_mac() { let path = repo_root().join(".gitea/workflows/release-artifact.yml"); let content = std::fs::read_to_string(&path).expect("read release-artifact.yml"); let value: serde_yaml::Value = serde_yaml::from_str(&content).expect("valid yaml"); let branches = value["on"]["push"]["branches"] .as_sequence() .expect("on.push.branches sequence") .iter() .map(|v| v.as_str().unwrap_or_default().to_string()) .collect::>(); assert_eq!(branches, vec!["master".to_string()]); let jobs = value["jobs"].as_mapping().expect("jobs mapping"); let (_, job) = jobs.iter().next().expect("at least one job defined"); let runs_on = job["runs-on"].as_sequence().expect("runs-on sequence"); let runs_on: Vec<&str> = runs_on.iter().filter_map(|v| v.as_str()).collect(); assert_eq!(runs_on, vec!["arm64-mac"]); let steps = job["steps"].as_sequence().expect("steps sequence"); let calls_publish_script = steps.iter().any(|step| { step.get("run") .and_then(|r| r.as_str()) .is_some_and(|s| s.contains("script/ci-publish-artifact")) }); assert!( calls_publish_script, "a step must invoke script/ci-publish-artifact" ); let references_channel_secrets = steps.iter().any(|step| { step.get("env") .and_then(|e| e.as_mapping()) .is_some_and(|env| { env.values().any(|v| { v.as_str().is_some_and(|s| { s.contains("secrets.HUSKIES_CHANNEL_URL") || s.contains("secrets.HUSKIES_CHANNEL_TOKEN") }) }) }) }); assert!( references_channel_secrets, "workflow must reference the channel secrets via ${{{{ secrets.* }}}}" ); } #[test] fn readme_documents_runner_label_and_secrets() { let path = repo_root().join(".gitea/workflows/README.md"); let content = std::fs::read_to_string(&path).expect("read .gitea/workflows/README.md"); assert!( content.contains("arm64-mac"), "README must document the runner label" ); assert!( content.contains("HUSKIES_CHANNEL_URL"), "README must document HUSKIES_CHANNEL_URL" ); assert!( content.contains("HUSKIES_CHANNEL_TOKEN"), "README must document HUSKIES_CHANNEL_TOKEN" ); }