package appexport import ( "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "io" "log" "os" "path/filepath" "strings" "testing" "time" ) // v0.124.0 Part 3 (§7D) — the .fab loop's proof at unit level: a real export through // executeExport produces a bundle that a real executeImport restores to identical content, and // a corrupted downloaded copy is REFUSED (the .fab's integrity is the gzip CRC + the manifest // segment validation — there is no per-file checksum; corruption breaks the extract, and the // import must fail loudly, not restore garbage). // rtProvider is a filesystem-only fake: a config-only app (no HDD, no volumes, no DB) so the // whole loop runs without docker. type rtProvider struct { stackDir string stacksDir string deployed bool running bool volumes []string started bool stopped int removed int savedEnv map[string]string } func (p *rtProvider) GetStackDir(string) (string, bool) { return p.stackDir, true } func (p *rtProvider) GetStackComposePath(string) (string, bool) { return filepath.Join(p.stackDir, "docker-compose.yml"), true } func (p *rtProvider) GetStackHDDMounts(string) []string { return nil } func (p *rtProvider) GetStackHDDPath(string) string { return "" } func (p *rtProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture // R-203: these fixtures use ENROLLED drive paths, where the namespace root IS the drive path. // Delegating keeps that identity explicit rather than hardcoding it. func (p *rtProvider) GetStackNamespaceRoot(name string) string { return p.GetStackHDDPath(name) } func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return nil, false } func (p *rtProvider) IsStackRunning(string) bool { return p.running } func (p *rtProvider) StopStack(string) error { p.stopped++; return nil } func (p *rtProvider) StartStack(string) error { p.started = true; return nil } func (p *rtProvider) GetStackDisplayName(n string) string { return "RT " + n } func (p *rtProvider) GetStackNeedsHDD(string) bool { return false } func (p *rtProvider) GetDockerVolumes(string) []string { return p.volumes } func (p *rtProvider) IsStackDeployed(string) bool { return p.deployed } func (p *rtProvider) GetDecryptedEnv(string) map[string]string { return nil } func (p *rtProvider) GetStacksBaseDir() string { return p.stacksDir } func (p *rtProvider) RefreshStacks() error { return nil } func (p *rtProvider) RemoveStackVolumes(string) error { p.removed++; return nil } func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error { p.savedEnv = env return nil } func waitJob(t *testing.T, e *Exporter) *Job { t.Helper() deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { job := e.GetActiveJob() if job != nil { job.mu.RLock() done := job.Done job.mu.RUnlock() if done { return job } } time.Sleep(50 * time.Millisecond) } t.Fatal("job did not finish in time") return nil } func jobErr(j *Job) string { j.mu.RLock() defer j.mu.RUnlock() if j.Error != "" { return j.Error } for _, s := range j.Steps { if s.Status == "failed" { return s.Error } } return "" } func TestFabRoundTrip_ExportImportContentEquality(t *testing.T) { const stack = "rt-app" lg := log.New(io.Discard, "", 0) // Source stack: a compose file + a marker config with known content. srcStack := t.TempDir() compose := "services:\n rt-app:\n image: alpine\n" marker := "MARKER-CONTENT-42\n" os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte(compose), 0644) os.WriteFile(filepath.Join(srcStack, "settings.conf"), []byte(marker), 0644) drive := t.TempDir() prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true} e := NewExporter(prov, lg, "test") // --- export (the REAL pipeline; same producer as a drive export) --- if err := e.StartExport(ExportRequest{StackName: stack, DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) if msg := jobErr(job); msg != "" { t.Fatalf("export failed: %s", msg) } entries, _ := os.ReadDir(ExportDir(drive)) var fabPath string for _, en := range entries { if strings.HasSuffix(en.Name(), ".fab") { fabPath = filepath.Join(ExportDir(drive), en.Name()) } } if fabPath == "" { t.Fatal("no .fab produced") } // The manifest is readable and names the app (what /api/export/manifest shows pre-import). man, err := ReadManifestFromFAB(fabPath) if err != nil { t.Fatalf("manifest: %v", err) } if man.AppName != stack { t.Fatalf("manifest app = %q", man.AppName) } // --- corrupted copy must be REFUSED (assert the refusal, §10 red-proof of the loop) --- corrupt := filepath.Join(t.TempDir(), "corrupt.fab") raw, _ := os.ReadFile(fabPath) mid := len(raw) / 2 raw[mid] ^= 0xFF raw[mid+1] ^= 0xFF os.WriteFile(corrupt, raw, 0644) prov2 := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false} e2 := NewExporter(prov2, lg, "test") if err := e2.StartImport(ImportRequest{FABPath: corrupt}); err != nil { t.Fatalf("StartImport(corrupt) should start (refusal is async): %v", err) } job = waitJob(t, e2) if msg := jobErr(job); msg == "" { t.Fatal("a corrupted bundle must FAIL the import (gzip CRC), got success") } if prov2.started { t.Fatal("a refused import must not start the app") } if _, err := os.Stat(filepath.Join(prov2.stacksDir, stack, "settings.conf")); !os.IsNotExist(err) { t.Fatal("a refused import must not restore content") } // --- the clean bundle round-trips: restored content is byte-identical --- prov3 := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false} e3 := NewExporter(prov3, lg, "test") if err := e3.StartImport(ImportRequest{FABPath: fabPath}); err != nil { t.Fatalf("StartImport: %v", err) } job = waitJob(t, e3) if msg := jobErr(job); msg != "" { t.Fatalf("import failed: %s", msg) } restoredStack := filepath.Join(prov3.stacksDir, stack) for name, want := range map[string]string{ "docker-compose.yml": compose, "settings.conf": marker, } { got, err := os.ReadFile(filepath.Join(restoredStack, name)) if err != nil { t.Fatalf("restored %s missing: %v", name, err) } if string(got) != want { t.Errorf("restored %s differs:\n got %q\nwant %q", name, got, want) } } if !prov3.started { t.Error("import must start the restored app") } }