package web import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-126 (operator ruling 2026-10-05, 09 §3 decision 128): an export WITHOUT a bundle password to a // network drive is refused; WITH a password it runs; a local drive without a password is unchanged. // The assertions are the consequence — whether a .fab lands on the destination — not only the status. func TestExportStart_NetworkDriveNeedsPassword(t *testing.T) { build := func(t *testing.T, kind string) (*Server, *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") prov := &fabWebProvider{stackDir: stackDir, stacksDir: t.TempDir(), hddPath: drive, binds: []appbackup.ClassifiedBind{ {ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/books"}, Class: appbackup.ClassMandatory}, }} e := appexport.NewExporter(prov, s.logger, "test") s.appExporter = e if err := s.settings.AddStoragePath(settings.StoragePath{Path: drive, Label: "d", Kind: kind, Schedulable: true}); err != nil { t.Fatal(err) } if got := s.settings.IsNetworkStoragePath(drive); got != (kind == settings.StorageKindNetwork) { t.Fatalf("fixture: IsNetworkStoragePath(%q)=%v for kind %q", drive, got, kind) } return s, e, drive } start := func(s *Server, drive, password string) (*httptest.ResponseRecorder, map[string]interface{}) { body, _ := json.Marshal(map[string]interface{}{"stack_name": "calibre-web", "dest_drive": drive, "password": password}) rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/export/start", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") s.apiExportStart(rr, req) var resp map[string]interface{} json.Unmarshal(rr.Body.Bytes(), &resp) return rr, resp } fabCount := func(dir string) int { n := 0 filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err == nil && strings.HasSuffix(p, ".fab") { n++ } return nil }) return n } t.Run("network drive, no password: refused, nothing written", func(t *testing.T) { s, e, drive := build(t, settings.StorageKindNetwork) rr, resp := start(s, drive, "") if rr.Code != http.StatusBadRequest || resp["ok"] != false { t.Fatalf("want 400 ok=false, got %d %s", rr.Code, rr.Body.String()) } msg, _ := resp["error"].(string) // ASCII fragments of the Hungarian sentence (positive), and never the raw key (negative). if !strings.Contains(msg, "(NAS)") || !strings.Contains(msg, "jelsz") || strings.Contains(msg, "app_export.") { t.Errorf("refusal text is not the household sentence: %q", msg) } if j := e.GetActiveJob(); j != nil { t.Errorf("an export job started despite the refusal: %v", j.Snapshot()) } if n := fabCount(drive); n != 0 { t.Errorf("%d .fab file(s) landed on the network drive without a password", n) } }) t.Run("network drive, with password: runs", func(t *testing.T) { s, e, drive := build(t, settings.StorageKindNetwork) rr, resp := start(s, drive, "correct horse battery") if rr.Code != http.StatusOK || resp["ok"] != true { t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String()) } waitExportDone(t, e) if n := fabCount(drive); n != 1 { t.Errorf("want 1 .fab on the network drive, got %d", n) } }) t.Run("local drive, no password: unchanged", func(t *testing.T) { s, e, drive := build(t, "") rr, resp := start(s, drive, "") if rr.Code != http.StatusOK || resp["ok"] != true { t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String()) } waitExportDone(t, e) if n := fabCount(drive); n != 1 { t.Errorf("want 1 .fab on the local drive, got %d", n) } }) }