2958946517
${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.
150 lines
5.6 KiB
Go
150 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// snapshotsStubProvider implements backup.StackDataProvider for the F1 endpoint tests: one known
|
|
// stack ("app") living on `hdd`. Everything else is inert.
|
|
type snapshotsStubProvider struct{ hdd string }
|
|
|
|
func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool) {
|
|
if name == "app" {
|
|
return filepath.Join(p.hdd, "compose", "docker-compose.yml"), true
|
|
}
|
|
return "", false
|
|
}
|
|
func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil }
|
|
func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil }
|
|
func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd }
|
|
func (p *snapshotsStubProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
|
func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil }
|
|
func (p *snapshotsStubProvider) StopStack(string) error { return nil }
|
|
func (p *snapshotsStubProvider) StartStack(string) error { return nil }
|
|
func (p *snapshotsStubProvider) RefreshAndIsRunning(string) bool { return true }
|
|
func (p *snapshotsStubProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
|
|
return backup.RecoveryInfo{}, false
|
|
}
|
|
func (p *snapshotsStubProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
|
|
return nil, false
|
|
}
|
|
func (p *snapshotsStubProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
|
func (p *snapshotsStubProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
|
return nil
|
|
}
|
|
func (p *snapshotsStubProvider) StartStackServices(string, []string) error { return nil }
|
|
|
|
// newSnapshotsRouter wires a Router with a real backup.Manager over a tempdir drive.
|
|
func newSnapshotsRouter(t *testing.T) (*Router, string) {
|
|
t.Helper()
|
|
drive := filepath.Join(t.TempDir(), "drive")
|
|
cfg := &config.Config{}
|
|
cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys")
|
|
mgr := backup.NewManager(cfg, nil, log.New(io.Discard, "", 0))
|
|
mgr.SetStackProvider(&snapshotsStubProvider{hdd: drive})
|
|
return &Router{cfg: cfg, backupMgr: mgr, logger: log.New(io.Discard, "", 0)}, drive
|
|
}
|
|
|
|
// getSnapshots dispatches through Router.ServeHTTP so the tests also prove the ROUTE is
|
|
// registered — the F1 bug was precisely a fetch to a route that did not exist.
|
|
func getSnapshots(t *testing.T, r *Router, rawQuery string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/backup/snapshots?"+rawQuery, nil)
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
type snapshotsResp struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error"`
|
|
Data []struct {
|
|
Time string `json:"time"`
|
|
ShortID string `json:"short_id"`
|
|
Tier int `json:"tier"`
|
|
DriveLabel string `json:"drive_label"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func decodeSnapshots(t *testing.T, rec *httptest.ResponseRecorder) snapshotsResp {
|
|
t.Helper()
|
|
var v snapshotsResp
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
|
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Scenario A (data half): a recovery unit on disk → 200 with EXACTLY ONE tier-1 "helyi" entry the
|
|
// restore panel JS can render and POST back.
|
|
func TestBackupSnapshots_UnitOnDisk(t *testing.T) {
|
|
r, drive := newSnapshotsRouter(t)
|
|
manifest := backup.RecoveryUnitManifestPath(drive, "app")
|
|
if err := os.MkdirAll(filepath.Dir(manifest), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(manifest, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := getSnapshots(t, r, "stack=app")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
v := decodeSnapshots(t, rec)
|
|
if !v.OK || len(v.Data) != 1 {
|
|
t.Fatalf("want ok with exactly 1 entry, got %+v", v)
|
|
}
|
|
if v.Data[0].Tier != 1 || v.Data[0].ShortID != "helyi" || v.Data[0].Time == "" {
|
|
t.Errorf("entry = %+v (want tier 1, short_id helyi, non-empty time)", v.Data[0])
|
|
}
|
|
}
|
|
|
|
// Scenario B: known stack, no recovery unit yet → ok:true with an EMPTY data list (the JS shows
|
|
// "Nincs elérhető mentés" and keeps the button disabled — reached honestly, not via a 404).
|
|
func TestBackupSnapshots_NoBackupYet(t *testing.T) {
|
|
r, _ := newSnapshotsRouter(t)
|
|
rec := getSnapshots(t, r, "stack=app")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
v := decodeSnapshots(t, rec)
|
|
if !v.OK || len(v.Data) != 0 {
|
|
t.Errorf("want ok with empty list, got %+v", v)
|
|
}
|
|
}
|
|
|
|
// Scenario C: guards — traversal and empty names are 400 BEFORE any filesystem work; an unknown
|
|
// stack is 404; a Router without a backup manager is 400.
|
|
func TestBackupSnapshots_Guards(t *testing.T) {
|
|
r, _ := newSnapshotsRouter(t)
|
|
|
|
for _, q := range []string{"stack=../../etc", "stack=", "stack=a/b", "stack=.."} {
|
|
rec := getSnapshots(t, r, q)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%q: status = %d, want 400; body=%s", q, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
rec := getSnapshots(t, r, "stack=ghost")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("unknown stack: status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
noMgr := &Router{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0)}
|
|
rec = getSnapshots(t, noMgr, "stack=app")
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("nil backupMgr: status = %d, want 400", rec.Code)
|
|
}
|
|
}
|