• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FlorianNAdam / nirion / 29265084150

13 Jul 2026 04:00PM UTC coverage: 77.376% (+1.4%) from 75.982%
29265084150

push

github

web-flow
tests: more stable unit test binary mocking (#23)

32 of 36 new or added lines in 6 files covered. (88.89%)

3981 of 5145 relevant lines covered (77.38%)

4.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.74
/nirion-lib/src/patch.rs
1
use std::{collections::BTreeMap, fs, process::Stdio};
2

3
use anyhow::{Context, Result};
4
use serde::Deserialize;
5
use tokio::process::Command;
6

7
use crate::projects::{Projects, TargetSelector};
8

9
#[cfg(test)]
10
static TEST_SUDO_CMD: std::sync::Mutex<Option<Vec<String>>> =
11
    std::sync::Mutex::new(None);
12

13
#[derive(Clone, Debug, PartialEq, Eq)]
14
pub enum PatchTarget {
15
    EnvFile,
16
    Compose,
17
}
18

19
pub async fn patch_target(
6✔
20
    target: &TargetSelector,
21
    projects: &Projects,
22
    patch_target: &PatchTarget,
23
) -> Result<()> {
24
    match target {
6✔
25
        TargetSelector::All => {
26
            anyhow::bail!("Only individual projects can be patched");
2✔
27
        }
28

29
        TargetSelector::Project(proj) => {
2✔
30
            if patch_target == &PatchTarget::EnvFile {
2✔
31
                anyhow::bail!(
1✔
32
                    "Only individual service env files can be patched"
1✔
33
                );
34
            }
35

36
            let project = &projects[&proj.name];
2✔
37
            patch(&project.docker_compose).await?;
2✔
38
        }
39

40
        TargetSelector::Service(img) => {
3✔
41
            let project = &projects[&img.project];
6✔
42

43
            match patch_target {
3✔
44
                PatchTarget::Compose => {
45
                    patch(&project.docker_compose).await?;
×
46
                }
47

48
                PatchTarget::EnvFile => {
49
                    let compose =
3✔
50
                        load_compose_env_files(&project.docker_compose)?;
6✔
51

52
                    let service = compose.services.get(&img.service).with_context(|| {
15✔
53
                        format!(
1✔
54
                            "Service `{}` not found in compose file for project `{}`",
1✔
55
                            img.service, img.project
56
                        )
57
                    })?;
58

59
                    let env_file =
1✔
60
                        service
2✔
61
                            .env_file
2✔
62
                            .first()
63
                            .with_context(|| {
3✔
64
                                format!(
1✔
65
                                    "No env_file found for `{}.{}`",
1✔
66
                                    img.project, img.service
67
                                )
68
                            })?;
69

70
                    patch(env_file).await?;
2✔
71
                }
72
            }
73
        }
74
    }
75

76
    Ok(())
2✔
77
}
78

79
fn load_compose_env_files(path: &str) -> anyhow::Result<ComposeFile> {
6✔
80
    let data = fs::read_to_string(path)
17✔
81
        .with_context(|| format!("Failed reading {}", path))?;
9✔
82

83
    serde_yaml_ng::from_str::<ComposeFile>(&data)
10✔
84
        .with_context(|| format!("Compose file parse error in {}", path))
7✔
85
}
86

87
#[derive(Debug, Deserialize)]
88
struct ComposeFile {
89
    services: BTreeMap<String, Service>,
90
}
91

92
#[derive(Debug, Deserialize)]
93
struct Service {
94
    #[serde(default)]
95
    env_file: Vec<String>,
96
}
97

98
pub async fn patch(file: &str) -> Result<()> {
10✔
99
    let status = sudo_command()
34✔
100
        .arg("mirage-patch")
101
        .arg(file)
25✔
102
        .stdin(Stdio::inherit())
20✔
103
        .stdout(Stdio::inherit())
15✔
104
        .stderr(Stdio::inherit())
10✔
105
        .status()
106
        .await
5✔
107
        .context("failed to spawn mirage-patch")?;
108

109
    if !status.success() {
4✔
110
        anyhow::bail!("mirage-patch exited with status: {}", status);
2✔
111
    }
112

113
    Ok(())
3✔
114
}
115

116
fn sudo_command() -> Command {
5✔
117
    #[cfg(test)]
118
    if let Some(cmd) = TEST_SUDO_CMD.lock().unwrap().clone() {
15✔
119
        let mut command = Command::new(&cmd[0]);
15✔
120
        command.args(&cmd[1..]);
15✔
121
        return command;
5✔
122
    }
123

NEW
124
    Command::new("sudo")
×
125
}
126

127
#[cfg(test)]
128
mod tests {
129
    use super::*;
130
    use std::{fs, io::Write, os::unix::fs::PermissionsExt, path::Path};
131

132
    static SUDO_BIN_LOCK: tokio::sync::Mutex<()> =
133
        tokio::sync::Mutex::const_new(());
134

135
    struct SudoBinGuard;
136

137
    impl SudoBinGuard {
138
        fn set_script(script: String) -> Self {
139
            *TEST_SUDO_CMD.lock().unwrap() =
140
                Some(vec!["/bin/sh".to_string(), script]);
141
            Self
142
        }
143

144
        fn set_command(command: String) -> Self {
145
            *TEST_SUDO_CMD.lock().unwrap() = Some(vec![command]);
146
            Self
147
        }
148
    }
149

150
    impl Drop for SudoBinGuard {
151
        fn drop(&mut self) {
152
            *TEST_SUDO_CMD.lock().unwrap() = None;
153
        }
154
    }
155

156
    fn write_fake_sudo(dir: &Path, args_file: &Path, exit_code: i32) -> String {
157
        let sudo = dir.join("sudo");
158
        let tmp = dir.join("sudo.tmp");
159
        let mut file = fs::File::create(&tmp).unwrap();
160
        file.write_all(
161
            format!(
162
                r#"#!/bin/sh
163
printf '%s\n' "$@" > '{}'
164
exit {exit_code}
165
"#,
166
                args_file.display()
167
            )
168
            .as_bytes(),
169
        )
170
        .unwrap();
171
        file.sync_all().unwrap();
172
        drop(file);
173

174
        let mut permissions = fs::metadata(&tmp)
175
            .unwrap()
176
            .permissions();
177
        permissions.set_mode(0o755);
178
        fs::set_permissions(&tmp, permissions).unwrap();
179
        fs::rename(&tmp, &sudo).unwrap();
180

181
        sudo.to_string_lossy().to_string()
182
    }
183

184
    fn projects(compose_path: &str) -> Projects {
185
        serde_json::from_value(serde_json::json!({
186
            "myapp": {
187
                "name": "myapp",
188
                "dockerCompose": compose_path,
189
                "services": {
190
                    "web": {
191
                        "image": "nginx",
192
                        "healthcheck": false,
193
                        "restart": null
194
                    }
195
                }
196
            }
197
        }))
198
        .unwrap()
199
    }
200

201
    fn service_target(service: &str) -> TargetSelector {
202
        TargetSelector::Service(crate::projects::ServiceSelector {
203
            project: "myapp".into(),
204
            service: service.into(),
205
        })
206
    }
207

208
    #[tokio::test]
209
    async fn patch_target_rejects_all_projects() {
210
        let projects = Projects::default();
211
        let err = patch_target(
212
            &TargetSelector::All,
213
            &projects,
214
            &PatchTarget::Compose,
215
        )
216
        .await
217
        .unwrap_err();
218

219
        assert_eq!(err.to_string(), "Only individual projects can be patched");
220
    }
221

222
    #[tokio::test]
223
    async fn patch_target_rejects_project_env_file() {
224
        let target =
225
            TargetSelector::Project(crate::projects::ProjectSelector {
226
                name: "myapp".into(),
227
            });
228
        let projects = projects("compose.yml");
229
        let err = patch_target(&target, &projects, &PatchTarget::EnvFile)
230
            .await
231
            .unwrap_err();
232

233
        assert_eq!(
234
            err.to_string(),
235
            "Only individual service env files can be patched"
236
        );
237
    }
238

239
    #[test]
240
    fn load_compose_env_files_reads_env_files() {
241
        let dir = tempfile::tempdir().unwrap();
242
        let compose_path = dir.path().join("compose.yml");
243
        std::fs::write(
244
            &compose_path,
245
            r#"
246
services:
247
  web:
248
    env_file:
249
      - web.env
250
      - common.env
251
  db:
252
    image: postgres
253
"#,
254
        )
255
        .unwrap();
256

257
        let compose =
258
            load_compose_env_files(compose_path.to_str().unwrap()).unwrap();
259

260
        assert_eq!(
261
            compose.services["web"].env_file,
262
            vec!["web.env", "common.env"]
263
        );
264
        assert!(
265
            compose.services["db"]
266
                .env_file
267
                .is_empty()
268
        );
269
    }
270

271
    #[test]
272
    fn load_compose_env_files_reports_missing_file() {
273
        let err =
274
            load_compose_env_files("/nonexistent/compose.yml").unwrap_err();
275

276
        assert!(
277
            err.to_string()
278
                .contains("Failed reading /nonexistent/compose.yml")
279
        );
280
    }
281

282
    #[test]
283
    fn load_compose_env_files_reports_invalid_yaml() {
284
        let dir = tempfile::tempdir().unwrap();
285
        let compose_path = dir.path().join("compose.yml");
286
        std::fs::write(&compose_path, "services: [").unwrap();
287

288
        let err =
289
            load_compose_env_files(compose_path.to_str().unwrap()).unwrap_err();
290

291
        assert!(
292
            err.to_string()
293
                .contains("Compose file parse error")
294
        );
295
    }
296

297
    #[tokio::test]
298
    async fn patch_target_reports_missing_service_for_env_file() {
299
        let dir = tempfile::tempdir().unwrap();
300
        let compose_path = dir.path().join("compose.yml");
301
        std::fs::write(
302
            &compose_path,
303
            r#"
304
services:
305
  web:
306
    env_file:
307
      - web.env
308
"#,
309
        )
310
        .unwrap();
311
        let projects = projects(compose_path.to_str().unwrap());
312

313
        let err = patch_target(
314
            &service_target("worker"),
315
            &projects,
316
            &PatchTarget::EnvFile,
317
        )
318
        .await
319
        .unwrap_err();
320

321
        assert_eq!(
322
            err.to_string(),
323
            "Service `worker` not found in compose file for project `myapp`"
324
        );
325
    }
326

327
    #[tokio::test]
328
    async fn patch_target_reports_missing_env_file() {
329
        let dir = tempfile::tempdir().unwrap();
330
        let compose_path = dir.path().join("compose.yml");
331
        std::fs::write(
332
            &compose_path,
333
            r#"
334
services:
335
  web:
336
    image: nginx
337
"#,
338
        )
339
        .unwrap();
340
        let projects = projects(compose_path.to_str().unwrap());
341

342
        let err = patch_target(
343
            &service_target("web"),
344
            &projects,
345
            &PatchTarget::EnvFile,
346
        )
347
        .await
348
        .unwrap_err();
349

350
        assert_eq!(err.to_string(), "No env_file found for `myapp.web`");
351
    }
352

353
    #[tokio::test]
354
    async fn patch_invokes_mirage_patch_through_sudo() {
355
        let _sudo_bin_lock = SUDO_BIN_LOCK.lock().await;
356
        let dir = tempfile::tempdir().unwrap();
357
        let args_file = dir.path().join("args");
358
        let sudo = write_fake_sudo(dir.path(), &args_file, 0);
359
        let _sudo_bin_guard = SudoBinGuard::set_script(sudo);
360

361
        patch("compose.yml").await.unwrap();
362

363
        assert_eq!(
364
            fs::read_to_string(args_file).unwrap(),
365
            "mirage-patch\ncompose.yml\n"
366
        );
367
    }
368

369
    #[tokio::test]
370
    async fn patch_reports_failed_status() {
371
        let _sudo_bin_lock = SUDO_BIN_LOCK.lock().await;
372
        let dir = tempfile::tempdir().unwrap();
373
        let args_file = dir.path().join("args");
374
        let sudo = write_fake_sudo(dir.path(), &args_file, 3);
375
        let _sudo_bin_guard = SudoBinGuard::set_script(sudo);
376

377
        let err = patch("compose.yml").await.unwrap_err();
378

379
        assert!(
380
            err.to_string()
381
                .contains("mirage-patch exited with status")
382
        );
383
    }
384

385
    #[tokio::test]
386
    async fn patch_reports_spawn_failure() {
387
        let _sudo_bin_lock = SUDO_BIN_LOCK.lock().await;
388
        let dir = tempfile::tempdir().unwrap();
389
        let missing_sudo = dir.path().join("missing-sudo");
390
        let _sudo_bin_guard = SudoBinGuard::set_command(
391
            missing_sudo
392
                .to_string_lossy()
393
                .to_string(),
394
        );
395

396
        let err = patch("compose.yml").await.unwrap_err();
397

398
        assert!(
399
            err.to_string()
400
                .contains("failed to spawn mirage-patch")
401
        );
402
    }
403

404
    #[tokio::test]
405
    async fn patch_target_patches_project_compose_file() {
406
        let _sudo_bin_lock = SUDO_BIN_LOCK.lock().await;
407
        let dir = tempfile::tempdir().unwrap();
408
        let args_file = dir.path().join("args");
409
        let sudo = write_fake_sudo(dir.path(), &args_file, 0);
410
        let _sudo_bin_guard = SudoBinGuard::set_script(sudo);
411
        let compose_path = dir.path().join("compose.yml");
412
        let projects = projects(compose_path.to_str().unwrap());
413

414
        patch_target(
415
            &TargetSelector::Project(crate::projects::ProjectSelector {
416
                name: "myapp".into(),
417
            }),
418
            &projects,
419
            &PatchTarget::Compose,
420
        )
421
        .await
422
        .unwrap();
423

424
        assert_eq!(
425
            fs::read_to_string(args_file).unwrap(),
426
            format!("mirage-patch\n{}\n", compose_path.display())
427
        );
428
    }
429

430
    #[tokio::test]
431
    async fn patch_target_patches_first_service_env_file() {
432
        let _sudo_bin_lock = SUDO_BIN_LOCK.lock().await;
433
        let dir = tempfile::tempdir().unwrap();
434
        let args_file = dir.path().join("args");
435
        let sudo = write_fake_sudo(dir.path(), &args_file, 0);
436
        let _sudo_bin_guard = SudoBinGuard::set_script(sudo);
437
        let compose_path = dir.path().join("compose.yml");
438
        std::fs::write(
439
            &compose_path,
440
            r#"
441
services:
442
  web:
443
    env_file:
444
      - web.env
445
      - common.env
446
"#,
447
        )
448
        .unwrap();
449
        let projects = projects(compose_path.to_str().unwrap());
450

451
        patch_target(&service_target("web"), &projects, &PatchTarget::EnvFile)
452
            .await
453
            .unwrap();
454

455
        assert_eq!(
456
            fs::read_to_string(args_file).unwrap(),
457
            "mirage-patch\nweb.env\n"
458
        );
459
    }
460
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc