package web import ( "io" "log" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // --- stub detection (the controller-namespace leg of the badge, RCA fix 2) -------------------------- func TestStubNetworkPaths_Classification(t *testing.T) { s := testServer(t) classes := map[string]string{ "/mnt/felhom-drives/stubbed": system.FSClassStub, "/mnt/felhom-drives/idle": system.FSClassAutofs, // healthy — never a stub "/mnt/felhom-drives/live": system.FSClassNetwork, // healthy "/mnt/felhom-drives/wedged": system.FSClassUnknown, // fail open — never a stub } s.classifyFSPath = func(p string) string { return classes[p] } netPaths := map[string]settings.StoragePath{} for p := range classes { netPaths[p] = settings.StoragePath{Path: p, Label: "L:" + p, Kind: settings.StorageKindNetwork} } got := s.stubNetworkPaths(netPaths) if len(got) != 1 { t.Fatalf("stub set = %v, want exactly the stubbed path", got) } if lbl := got["/mnt/felhom-drives/stubbed"]; lbl != "L:/mnt/felhom-drives/stubbed" { t.Fatalf("stub label = %q", lbl) } } // --- per-stack mapping: stub wins, unreachable byte-behavior preserved ------------------------------- func TestNetworkStorageWarningsIn_StubWins(t *testing.T) { list := []stacks.Stack{ {Name: "cwa", Deployed: true}, {Name: "jellyfin", Deployed: true}, {Name: "undeployed", Deployed: false}, } env := map[string]map[string]string{ "cwa": {"HDD_PATH": "/mnt/felhom-drives/media"}, "jellyfin": {"HDD_PATH": "/mnt/felhom-drives/other"}, "undeployed": {"HDD_PATH": "/mnt/felhom-drives/media"}, } load := func(name string) *stacks.AppConfig { if e, ok := env[name]; ok { return &stacks.AppConfig{Env: e} } return nil } unreachable := map[string]string{ "/mnt/felhom-drives/media": "Média", // ALSO unreachable — stub must win "/mnt/felhom-drives/other": "Másik", } stubPaths := map[string]string{"/mnt/felhom-drives/media": "Média"} warnings, stubs := networkStorageWarningsIn(list, load, unreachable, stubPaths) if len(stubs) != 1 || stubs["cwa"] != "Média" { t.Fatalf("stubs = %v, want cwa only (deployed, on the stub path)", stubs) } if _, both := warnings["cwa"]; both { t.Fatalf("cwa must not ALSO carry the unreachable badge (stub wins): %v", warnings) } if len(warnings) != 1 || warnings["jellyfin"] != "Másik" { t.Fatalf("warnings = %v, want jellyfin only (unreachable-alone → unchanged behavior)", warnings) } } func TestNetworkStorageWarningsIn_UnreachableAloneUnchanged(t *testing.T) { list := []stacks.Stack{{Name: "jellyfin", Deployed: true}} load := func(string) *stacks.AppConfig { return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": "/mnt/felhom-drives/media"}} } warnings, stubs := networkStorageWarningsIn(list, load, map[string]string{"/mnt/felhom-drives/media": "Média"}, map[string]string{}) if len(warnings) != 1 || warnings["jellyfin"] != "Média" || len(stubs) != 0 { t.Fatalf("unreachable-alone must keep the old shape: warnings=%v stubs=%v", warnings, stubs) } } // The two badge sentences are distinct strings and both present in the templates (the stub badge // must never reuse the recoverable-unreachable copy; the unreachable line stays byte-identical). func TestNetworkBadgeTemplates_DistinctStrings(t *testing.T) { for _, tpl := range []string{"templates/dashboard.html", "templates/stacks.html"} { body, err := huTemplateSource(tpl) if err != nil { t.Fatal(err) } s := string(body) if !strings.Contains(s, "Hálózati tárhely nem elérhető: {{$nw}}") { t.Errorf("%s: the unreachable badge line changed (must stay byte-identical)", tpl) } if !strings.Contains(s, "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja") { t.Errorf("%s: the stub badge string missing", tpl) } } } // --- rider (RCA fix 4): the deployed-app storage select shows the STORED HDD_PATH ------------------- // testDeployPageServer: a real Manager over a temp stacks dir holding one deployed app with a // path-type deploy field, plus two registered storage paths (the default ≠ the app's stored path — // the exact pre-fix trap). func testDeployPageServer(t *testing.T, storedHDD string) *Server { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Customer.ID = "test-customer" cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Stacks.ComposeCommand = "docker compose" stackDir := filepath.Join(cfg.Paths.StacksDir, "testapp") if err := os.MkdirAll(stackDir, 0o755); err != nil { t.Fatal(err) } meta := `display_name: Testapp deploy_fields: - env_var: HDD_PATH label: "Tárhely útvonal" type: path required: true ` if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { t.Fatal(err) } appYAML := "deployed: true\nenv:\n HDD_PATH: " + storedHDD + "\nlocked_fields:\n - HDD_PATH\n" if err := os.WriteFile(filepath.Join(stackDir, "app.yaml"), []byte(appYAML), 0o644); err != nil { t.Fatal(err) } sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } // The default drive is NOT the app's stored path — pre-fix the select showed this one. if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/felhom-usb", Label: "Tárhely (felhom-usb)", IsDefault: true, Schedulable: true}); err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/nas-media", Label: "Hálózati tárhely: nas-media", Schedulable: true, Kind: settings.StorageKindNetwork}); err != nil { t.Fatal(err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } // ScanStacks registers the stack dirs first and only then refreshes container status via // `docker ps` — tolerate that last step failing on docker-less test hosts, but require the // stack itself to have been discovered. _ = mgr.ScanStacks() if _, ok := mgr.GetStack("testapp"); !ok { t.Fatal("testapp not discovered by ScanStacks") } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.classifyFSPath = func(string) string { return system.FSClassUnknown } s.loadTemplates() return s } // Deployed on the NAS → the „Tarhely" row names the NAS, never the default drive. // v0.319.0: the settings tab shows the storage as a row, not a disabled select — the RCA fix 4 rule is // unchanged: the view states the STORED HDD_PATH. // Companion red-proof: make the row read the default drive's label → the first assertion FAILS (the exact // S-C lie from the RCA). func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) { s := testDeployPageServer(t, "/mnt/felhom-drives/nas-media") // v0.315.0: an installed app's fields are the app page's settings TAB. The old /stacks/…/deploy // URL answers 302 here, which TestAppPage_OldURLsRedirectGETOnly pins separately. rec := getPage(t, s, "/apps/testapp/settings") if rec.Code != 200 { t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String()) } row := storageRow(t, rec.Body.String()) if !strings.Contains(row, "nas-media") { t.Errorf("the storage row must name the STORED path's drive, got: %s", row) } if strings.Contains(row, "felhom-usb") { t.Errorf("the storage row must NOT name the default drive for a deployed app, got: %s", row) } } // storageRow returns the settings tab's „Tarhely" value cell. func storageRow(t *testing.T, body string) string { t.Helper() i := strings.Index(body, `data-app-storage-row="1"`) if i < 0 { t.Fatalf("no storage row in the rendered settings tab") } return body[i : i+strings.Index(body[i:], "")] } // Stored path absent from the schedulable list → the row names it verbatim as not available (the view // must never silently show a different storage than app.yaml). func TestDeployPage_MissingStoredPathRendersTruthOption(t *testing.T) { s := testDeployPageServer(t, "/mnt/felhom-drives/gone") rec := getPage(t, s, "/apps/testapp/settings") if rec.Code != 200 { t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String()) } if row := storageRow(t, rec.Body.String()); !strings.Contains(row, "/mnt/felhom-drives/gone (nem elérhető)") { t.Errorf("a missing stored path must render as its own path, not available; got: %s", row) } }