Files
felhom-controller/controller/internal/web/fab_export_test.go
T
admin 2958946517 v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).

Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.

Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.

Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.

One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.

Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.

Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.

Tests 915 -> 949, all green. MinAgent unchanged.
2026-07-26 08:12:57 +02:00

212 lines
7.5 KiB
Go

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) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
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
}