package appexport import ( "context" "fmt" "io" "log" "os" "path/filepath" "strings" "sync" "testing" ) // v0.125.0 — the containerized-.fab volume-strand fix (IA finding 1, HIGH). These tests pin: // (B) export can no longer lie — a stranded/empty tar aborts the export, no bundle; // (C) import validates BEFORE it destroys — a hollow bundle is refused with the app untouched; // the docker-cp command construction (image, mount shape, cp direction) and the always-remove // helper-container rule. The real docker legs are covered by the §3 live probe + §13 round-trip. // dockerCall records one dockerExec invocation. type dockerCall struct { args []string stdin bool } // swapDockerExec installs a scripted fake for the package seam and restores it on cleanup. func swapDockerExec(t *testing.T, fn func(call dockerCall, stdin io.Reader, stdout io.Writer) (string, error)) *[]dockerCall { t.Helper() var mu sync.Mutex calls := &[]dockerCall{} orig := dockerExec dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) { mu.Lock() c := dockerCall{args: append([]string{}, args...), stdin: stdin != nil} *calls = append(*calls, c) mu.Unlock() return fn(c, stdin, stdout) } t.Cleanup(func() { dockerExec = orig }) return calls } func volTestExporter(t *testing.T, volumes []string) (*Exporter, *rtProvider, string) { t.Helper() srcStack := t.TempDir() os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n vol-app:\n image: alpine\n"), 0644) prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true, volumes: volumes} drive := t.TempDir() return NewExporter(prov, log.New(io.Discard, "", 0), "test"), prov, drive } // Scenario B: a volume tar that fails to materialize (cp "succeeds" but writes nothing — the // strand's signature) must FAIL the export with the volume named, and NO bundle may exist. // Red-proof: remove the assertBundleDataComplete call → this test fails (hollow success). func TestExport_HollowVolumeTarAbortsExport(t *testing.T) { swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { switch c.args[0] { case "create": fmt.Fprint(stdout, "cid-123\n") return "", nil case "cp": return "", nil // writes NOTHING to stdout — the stranded-tar signature case "rm": return "", nil } return "", fmt.Errorf("unexpected docker call: %v", c.args) }) e, _, drive := volTestExporter(t, []string{"vol1"}) if err := e.StartExport(ExportRequest{StackName: "vol-app", DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) msg := jobErr(job) if msg == "" { t.Fatal("a hollow volume tar must FAIL the export — got success") } if !strings.Contains(msg, "vol1") { t.Errorf("the error must NAME the missing volume, got %q", msg) } entries, _ := os.ReadDir(ExportDir(drive)) for _, en := range entries { if strings.HasSuffix(en.Name(), ".fab") { t.Fatalf("a bundle was produced despite the hollow tar: %s", en.Name()) } } } // Scenario C: a bundle whose manifest claims a volume without its tar is refused BEFORE any // destructive step — the app is not stopped, no volume is removed or recreated, zero docker // calls happen. Red-proof: disable the pre-flight (pre-fix order: wipe first, discover later) // → the zero-destruction assertions fail. func TestImport_HollowBundleRefusedBeforeDestroy(t *testing.T) { calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { return "", nil }) // Handcraft a hollow bundle: manifest CLAIMS volume data, data/volumes is empty. tree := t.TempDir() os.MkdirAll(filepath.Join(tree, "config"), 0755) os.MkdirAll(filepath.Join(tree, "data", "volumes"), 0755) os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services: {}\n"), 0644) man := &Manifest{ Version: ManifestVersion, AppName: "vol-app", DisplayName: "Vol App", HasVolumeData: true, VolumeNames: []string{"vol1"}, ConfigFiles: []string{"docker-compose.yml"}, } data, err := man.Marshal() if err != nil { t.Fatal(err) } os.WriteFile(filepath.Join(tree, "manifest.json"), data, 0644) fab := filepath.Join(t.TempDir(), "hollow.fab") if err := createTarGz(fab, tree); err != nil { t.Fatalf("createTarGz: %v", err) } prov := &rtProvider{stackDir: t.TempDir(), stacksDir: t.TempDir(), deployed: true, running: true} e := NewExporter(prov, log.New(io.Discard, "", 0), "test") if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil { t.Fatalf("StartImport: %v", err) } job := waitJob(t, e) msg := jobErr(job) if msg == "" { t.Fatal("a hollow bundle must be REFUSED — got success") } if !strings.Contains(msg, "érintetlen") { t.Errorf("refusal copy must state the app is untouched, got %q", msg) } // THE exact non-effects: nothing was stopped, wiped, recreated or started. if prov.stopped != 0 || prov.removed != 0 { t.Fatalf("refusal happened AFTER destruction: stopped=%d removedVolumes=%d", prov.stopped, prov.removed) } if prov.started { t.Fatal("a refused import must not start the app") } if len(*calls) != 0 { t.Fatalf("a refused import must make ZERO docker calls, got %v", *calls) } } // Command construction + helper hygiene: the export leg uses create/cp/rm with the exact arg // shapes the §3 probe validated, and the helper container is force-removed EVEN when cp fails. func TestExportVolumeTar_CommandShapesAndHelperCleanup(t *testing.T) { t.Run("happy path shapes", func(t *testing.T) { calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { switch c.args[0] { case "create": fmt.Fprint(stdout, "cid-abc\n") case "cp": fmt.Fprint(stdout, "TARBYTES") } return "", nil }) e, _, _ := volTestExporter(t, nil) tarPath := filepath.Join(t.TempDir(), "v.tar") if err := e.exportVolumeTar("vol1", tarPath); err != nil { t.Fatalf("exportVolumeTar: %v", err) } got, _ := os.ReadFile(tarPath) if string(got) != "TARBYTES" { t.Fatalf("tar content = %q", got) } want := [][]string{ {"create", "-v", "vol1:/vol", "alpine", "true"}, {"cp", "cid-abc:/vol/.", "-"}, {"rm", "-f", "cid-abc"}, } assertCalls(t, *calls, want) }) t.Run("helper removed on cp failure", func(t *testing.T) { calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { switch c.args[0] { case "create": fmt.Fprint(stdout, "cid-err\n") return "", nil case "cp": return "boom", fmt.Errorf("cp failed") } return "", nil }) e, _, _ := volTestExporter(t, nil) tarPath := filepath.Join(t.TempDir(), "v.tar") if err := e.exportVolumeTar("vol1", tarPath); err == nil { t.Fatal("cp failure must surface") } if _, err := os.Stat(tarPath); !os.IsNotExist(err) { t.Error("a failed export must not leave a partial tar") } last := (*calls)[len(*calls)-1] if strings.Join(last.args, " ") != "rm -f cid-err" { t.Fatalf("helper container must be force-removed on failure, last call: %v", last.args) } }) t.Run("import leg shapes", func(t *testing.T) { calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { if c.args[0] == "create" { fmt.Fprint(stdout, "cid-imp\n") } if c.args[0] == "cp" && !c.stdin { t.Error("import cp must stream the tar on stdin") } return "", nil }) e, _, _ := volTestExporter(t, nil) tarPath := filepath.Join(t.TempDir(), "v.tar") os.WriteFile(tarPath, []byte("TAR"), 0644) if err := e.importVolumeTar("vol1", tarPath); err != nil { t.Fatalf("importVolumeTar: %v", err) } want := [][]string{ {"create", "-v", "vol1:/vol", "alpine", "true"}, {"cp", "-", "cid-imp:/vol"}, {"rm", "-f", "cid-imp"}, } assertCalls(t, *calls, want) }) } func assertCalls(t *testing.T, got []dockerCall, want [][]string) { t.Helper() if len(got) != len(want) { t.Fatalf("docker calls = %d, want %d (%v)", len(got), len(want), got) } for i := range want { if strings.Join(got[i].args, " ") != strings.Join(want[i], " ") { t.Errorf("call %d = %v, want %v", i, got[i].args, want[i]) } } }