package web import ( "archive/tar" "compress/gzip" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // fabWebProvider is a minimal appexport.ExportStackProvider: a config+userdata app (no volumes, no DB) // so a real export runs without docker, and classified so the plan applies. type fabWebProvider struct { stackDir, stacksDir, hddPath string binds []appbackup.ClassifiedBind } func (p *fabWebProvider) GetStackDir(string) (string, bool) { return p.stackDir, true } func (p *fabWebProvider) GetStackComposePath(string) (string, bool) { return filepath.Join(p.stackDir, "docker-compose.yml"), true } func (p *fabWebProvider) GetStackHDDMounts(string) []string { return []string{appbackup.UserdataDir(p.hddPath)} } func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath } func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return p.binds, true } func (p *fabWebProvider) IsStackRunning(string) bool { return false } func (p *fabWebProvider) StopStack(string) error { return nil } func (p *fabWebProvider) StartStack(string) error { return nil } func (p *fabWebProvider) GetStackDisplayName(n string) string { return n } func (p *fabWebProvider) GetStackNeedsHDD(string) bool { return true } func (p *fabWebProvider) GetDockerVolumes(string) []string { return nil } func (p *fabWebProvider) IsStackDeployed(string) bool { return true } func (p *fabWebProvider) GetDecryptedEnv(string) map[string]string { return nil } func (p *fabWebProvider) GetStacksBaseDir() string { return p.stacksDir } func (p *fabWebProvider) SaveEncryptedAppConfig(string, map[string]string) error { return nil } func (p *fabWebProvider) RefreshStacks() error { return nil } func (p *fabWebProvider) RemoveStackVolumes(string) error { return nil } // waitExportDone polls the exporter until the active job finishes. func waitExportDone(t *testing.T, e *appexport.Exporter) { t.Helper() deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if j := e.GetActiveJob(); j != nil { if done, _ := j.Snapshot()["done"].(bool); done { return } } time.Sleep(50 * time.Millisecond) } t.Fatal("export did not finish") } // findFab returns the newest .fab under dir. func findFab(t *testing.T, dir string) string { t.Helper() var found string filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err == nil && strings.HasSuffix(p, ".fab") { found = p } return nil }) if found == "" { t.Fatalf("no .fab under %s", dir) } return found } // userdataTarEntries extracts data/hdd/userdata.tar from an UNENCRYPTED .fab and lists its entries. func userdataTarEntries(t *testing.T, fabPath string) []string { t.Helper() f, err := os.Open(fabPath) if err != nil { t.Fatal(err) } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { t.Fatal(err) } tr := tar.NewReader(gz) var inner []byte for { h, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } if strings.HasSuffix(filepath.ToSlash(h.Name), "hdd/userdata.tar") { inner, _ = io.ReadAll(tr) } } if inner == nil { return nil // no userdata tar in the bundle } itr := tar.NewReader(strings.NewReader(string(inner))) var names []string for { h, err := itr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } names = append(names, filepath.ToSlash(h.Name)) } return names } // ยง7-F / red-proof F: BOTH start pipelines must carry the class selection. Each produces a bundle; // with OptInExcluded, the excluded content must be present in the userdata tar. Red-proof: drop the // selection fields from one start handler โ†’ that pipeline's assertion fails (two-call-site). func TestFab_SelectionsRideBothStartPipelines(t *testing.T) { build := func(t *testing.T) (*Server, *fabWebProvider, *appexport.Exporter, string) { s := testServer(t) s.cfg.Paths.DataDir = t.TempDir() drive := t.TempDir() stackDir := t.TempDir() os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644) fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK") fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE") // excluded, opted-in below prov := &fabWebProvider{stackDir: stackDir, stacksDir: t.TempDir(), hddPath: drive, binds: []appbackup.ClassifiedBind{ {ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/books"}, Class: appbackup.ClassMandatory}, {ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/movies"}, Class: appbackup.ClassExcluded}, }} e := appexport.NewExporter(prov, s.logger, "test") s.appExporter = e if err := s.settings.AddStoragePath(settings.StoragePath{Path: drive, Label: "d", Schedulable: true}); err != nil { t.Fatal(err) } return s, prov, e, drive } post := func(s *Server, path, body string, h func(http.ResponseWriter, *http.Request)) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") h(rr, req) var resp map[string]interface{} json.Unmarshal(rr.Body.Bytes(), &resp) if resp["ok"] != true { // jsonResponse uses {"ok":true}; some paths wrap in {"data":..}. Accept 200. if rr.Code != http.StatusOK { panic("start failed: " + rr.Body.String()) } } } // Pipeline 1: /api/export/start (dest = the registered drive). t.Run("api/export/start", func(t *testing.T) { s, _, e, drive := build(t) body, _ := json.Marshal(map[string]interface{}{"stack_name": "calibre-web", "dest_drive": drive, "opt_in_excluded": []string{"userdata/media/movies"}}) post(s, "/api/export/start", string(body), s.apiExportStart) waitExportDone(t, e) got := userdataTarEntries(t, findFab(t, filepath.Join(drive, "exports"))) if !hasSuffixIn(got, "media/movies/big.mkv") { t.Errorf("start pipeline dropped opt_in_excluded โ€” movies absent: %v", got) } }) // Pipeline 2: /api/export/download/start (dest = the staging download dir). t.Run("api/export/download/start", func(t *testing.T) { s, _, e, _ := build(t) body := `{"stack_name":"calibre-web","opt_in_excluded":["userdata/media/movies"]}` post(s, "/api/export/download/start", body, s.apiExportDownloadStart) waitExportDone(t, e) got := userdataTarEntries(t, findFab(t, s.fabDownloadDir())) if !hasSuffixIn(got, "media/movies/big.mkv") { t.Errorf("download pipeline dropped opt_in_excluded โ€” movies absent: %v", got) } }) } func fabWrite(t *testing.T, root, rel, content string) { t.Helper() p := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0644); err != nil { t.Fatal(err) } } func hasSuffixIn(ss []string, suffix string) bool { for _, s := range ss { if strings.HasSuffix(filepath.ToSlash(s), suffix) { return true } } return false }