use reqwest::Client; use std::collections::HashMap; use std::env; use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Stdio; use test_context::AsyncTestContext; use testdir::testdir; use tokio::fs; use tokio::process::Command; use eyeballs_api::api_model; pub struct DockerComposeContext { docker_dir: PathBuf, test_dir: PathBuf, url: String, remote_git: String, remote_git_key: PathBuf, } async fn run(cmd: &mut Command, name: &str) -> Result<(), anyhow::Error> { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); let child = cmd.spawn()?; let output = child.wait_with_output().await?; if output.status.success() { Ok(()) } else { Err(anyhow::Error::msg(format!( "{name} failed with exitcode: {}\n{:?}\n{}", output.status, cmd.as_std().get_args(), std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), ))) } } async fn setup_ssh_file( base: impl AsRef, host: &str, port: &str, identity_file: impl AsRef, ) -> Result<(), anyhow::Error> { let full_identity_file = identity_file.as_ref().canonicalize()?; fs::write( base.as_ref().join("ssh_config"), format!( "Host {host} StrictHostKeyChecking no UserKnownHostsFile /dev/null UpdateHostKeys no Port {port} User git IdentityFile {} ", full_identity_file.to_str().unwrap(), ), ) .await?; Ok(()) } async fn git_clone(base: impl AsRef, remote: &str) -> Result<(), anyhow::Error> { let mut cmd = Command::new("git"); cmd.arg("clone"); cmd.arg(remote); cmd.env("GIT_SSH_COMMAND", "ssh -F ssh_config"); cmd.current_dir(base); run(&mut cmd, "git clone").await } async fn git_cmd(base: impl AsRef, args: &[&str]) -> Result<(), anyhow::Error> { let mut cmd = Command::new("git"); cmd.arg("-C"); cmd.arg("fake"); for arg in args { cmd.arg(arg); } cmd.env("GIT_SSH_COMMAND", "ssh -F ../ssh_config"); cmd.current_dir(base); run(&mut cmd, "git command").await } const STRINGS_GRD: &str = r#" Main test string "#; const EXTRA_GRDP: &str = r#" Extra string, gray "#; const STRINGS_EN_GB_XLF: &str = r#" Main test string Main test string MAIN_STRING Description Extra string, gray Extra string, grey EXTRA_STRING Some description "#; pub const STRINGS_SV_XLF: &str = r#" Main test string Primära teststrängen MAIN_STRING Description Extra string, gray Extra sträng, grå EXTRA_STRING Some description "#; impl DockerComposeContext { pub fn url(&self) -> &str { self.url.as_str() } pub fn remote_git(&self) -> &str { self.remote_git.as_str() } pub fn remote_git_key(&self) -> &Path { self.remote_git_key.as_ref() } pub async fn setup_ssh_key(&self, base: &str, key: &str) -> Result<(), anyhow::Error> { let base_dir = self.test_dir.join(base); fs::create_dir(&base_dir).await?; let identity_file = base_dir.join("id_key"); fs::write(&identity_file, key).await?; let permissions = Permissions::from_mode(0o600); fs::set_permissions(&identity_file, permissions).await?; setup_ssh_file(&base_dir, "localhost", "10022", &identity_file).await?; Ok(()) } pub async fn git_clone(&self, base: &str) -> Result<(), anyhow::Error> { git_clone(self.test_dir.join(base), "ssh://localhost/srv/git/fake").await } pub fn git_dir(&self, base: &str) -> PathBuf { self.test_dir.join(base).join("fake") } pub async fn git_cmd(&self, base: &str, args: &[&str]) -> Result<(), anyhow::Error> { git_cmd(self.test_dir.join(base), args).await } } impl AsyncTestContext for DockerComposeContext { async fn setup() -> DockerComposeContext { let cargo_dir = match env::var("CARGO_MANIFEST_DIR") { Ok(pathstr) => PathBuf::from(pathstr), Err(e) => panic!("CARGO_MANIFEST_DIR not set: {e:?}"), }; let docker_dir = cargo_dir.join("../docker/integration_test"); let remote_git_key = docker_dir.join("web/gitkey"); let ctx = DockerComposeContext { docker_dir: docker_dir, test_dir: testdir!(), url: "http://localhost:18000".to_string(), remote_git: "ssh://git@remote_git/srv/git/fake.git".to_string(), remote_git_key, }; // Build githook, needs to use musl to work with the rockstorm/git-server image { let mut cmd = Command::new("cargo"); cmd.arg("build"); cmd.arg("--target=x86_64-unknown-linux-musl"); cmd.arg("--package"); cmd.arg("eyeballs-githook"); cmd.current_dir(cargo_dir); run(&mut cmd, "cargo build eyeballs-githook") .await .expect("cargo build"); } // Start docker compose up { let mut cmd = Command::new("docker"); cmd.arg("compose"); cmd.arg("up"); // Build images before starting containers cmd.arg("--build"); // Recreate anonymous volumes instead of retrieving data from the previous containers cmd.arg("--renew-anon-volumes"); // Detached mode: Run containers in the background cmd.arg("--detach"); // Wait for services to be running|healthy. Implies detached mode cmd.arg("--wait"); // Assume "yes" as answer to all prompts and run non-interactively cmd.arg("-y"); cmd.current_dir(&ctx.docker_dir); run(&mut cmd, "docker compose up") .await .expect("docker compose up"); } let mod_path = ctx.test_dir.join("mod"); fs::create_dir(&mod_path).await.expect("create mod"); setup_ssh_file( &mod_path, "localhost", "12222", ctx.docker_dir.join("web/gitkey"), ) .await .expect("ssh_config for remote_git"); // Setup fake remote repo { let mut cmd = Command::new("ssh"); cmd.arg("-F"); cmd.arg("ssh_config"); cmd.arg("localhost"); cmd.arg("mkdir /srv/git/fake.git"); cmd.current_dir(&mod_path); run(&mut cmd, "mkdir").await.expect("ssh mkdir"); } { let mut cmd = Command::new("ssh"); cmd.arg("-F"); cmd.arg("ssh_config"); cmd.arg("localhost"); cmd.arg("git-init --bare --initial-branch=main /srv/git/fake.git"); cmd.current_dir(&mod_path); run(&mut cmd, "git-init").await.expect("ssh git-init"); } git_clone(&mod_path, "ssh://localhost/srv/git/fake.git") .await .expect("git clone"); fs::write(mod_path.join("fake/README"), "Hello fellow fake person!") .await .expect("Write README"); git_cmd(&mod_path, &["add", "README"]) .await .expect("git add README"); git_cmd(&mod_path, &["commit", "-m", "Initial commit"]) .await .expect("git commit README"); git_cmd(&mod_path, &["push", "origin", "HEAD:main"]) .await .expect("git push"); fs::write(mod_path.join("fake/strings.grd"), STRINGS_GRD) .await .expect("Write strings.grd"); fs::write(mod_path.join("fake/extra.grdp"), EXTRA_GRDP) .await .expect("Write extra.grdp"); fs::create_dir(mod_path.join("fake/translations")) .await .expect("mkdir translations"); fs::write( mod_path.join("fake/translations/strings_en_gb.xlf"), STRINGS_EN_GB_XLF, ) .await .expect("Write strings_en_gb.xlf"); fs::write( mod_path.join("fake/translations/strings_sv.xlf"), STRINGS_SV_XLF, ) .await .expect("Write strings_sv"); git_cmd( &mod_path, &[ "add", "strings.grd", "extra.grdp", "translations/strings_en_gb.xlf", "translations/strings_sv.xlf", ], ) .await .expect("git add"); git_cmd(&mod_path, &["commit", "-m", "Add strings"]) .await .expect("git commit"); git_cmd(&mod_path, &["push", "origin", "HEAD:main"]) .await .expect("git push"); ctx } async fn teardown(self) { let mut cmd = Command::new("docker"); cmd.arg("compose"); cmd.arg("down"); // Remove named volumes declared in the "volumes" section of the Compose file and anonymous // volumes attached to containers cmd.arg("--volumes"); cmd.current_dir(&self.docker_dir); run(&mut cmd, "docker compose down") .await .expect("docker compose down"); } } pub fn create_client() -> Result { Ok(Client::builder().cookie_store(true).build()?) } pub async fn login( ctx: &mut DockerComposeContext, client: &mut Client, username: &str, password: &str, ) -> Result<(), anyhow::Error> { let mut params = HashMap::new(); params.insert("username", username); params.insert("password", password); let result = client .post(format!("{}/api/v1/login", ctx.url())) .form(¶ms) .send() .await?; if result.status().is_success() { Ok(()) } else { let content = result.text().await?; Err(anyhow::Error::msg(content)) } } pub async fn user_key_add( ctx: &mut DockerComposeContext, client: &mut Client, kind: &str, data: &str, ) -> Result { let data = api_model::UserKeyData { kind, data: data, comment: None, }; let result = client .post(format!("{}/api/v1/user/keys/add", ctx.url())) .json(&data) .send() .await?; if result.status().is_success() { let project = result.json::().await?; Ok(project) } else { let content = result.text().await?; Err(anyhow::Error::msg(content)) } } pub async fn create_project( ctx: &mut DockerComposeContext, client: &mut Client, projectid: &str, remote: &str, remote_key: &Path, ) -> Result { let remote_key_data = fs::read_to_string(remote_key).await?; let data = api_model::ProjectData { title: None, description: None, remote: Some(remote), remote_key: Some(remote_key_data), main_branch: None, }; let result = client .post(format!("{}/api/v1/project/{projectid}/new", ctx.url())) .json(&data) .send() .await?; if result.status().is_success() { let project = result.json::().await?; Ok(project) } else { let content = result.text().await?; Err(anyhow::Error::msg(content)) } } pub async fn list_reviews( ctx: &mut DockerComposeContext, client: &mut Client, projectid: &str, ) -> Result { let result = client .get(format!("{}/api/v1/project/{projectid}/reviews", ctx.url())) .send() .await?; if result.status().is_success() { let project = result.json::().await?; Ok(project) } else { let content = result.text().await?; Err(anyhow::Error::msg(content)) } } pub async fn create_translation_review( ctx: &mut DockerComposeContext, client: &mut Client, projectid: &str, ) -> Result { let data = api_model::TranslationReviewData { title: "Test".to_string(), description: "Some test".to_string(), base: None, }; let result = client .post(format!("{}/api/v1/translation/{projectid}/new", ctx.url())) .json(&data) .send() .await?; if result.status().is_success() { let review = result.json::().await?; Ok(review) } else { let content = result.text().await?; Err(anyhow::Error::msg(content)) } } pub async fn list_translation_strings( ctx: &mut DockerComposeContext, client: &mut Client, translation_reviewid: u64, ) -> Result { let result = client .get(format!( "{}/api/v1/translation/{translation_reviewid}/strings", ctx.url() )) .send() .await?; if result.status().is_success() { let strings = result.json::().await?; Ok(strings) } else { let content = result.text().await?; Err(anyhow::Error::msg(content)) } }