Files
felhom-controller/controller/internal/web/networkstub_test.go
T
admin d170a2aecf
gates / gates (push) Successful in 52s
v0.319.0: the app page in panels, the login in one place, a password the box never had is said in words
- style A (operator 2026-10-16): hero, tab bar and every section are panels on --bg-1, one width, 16px apart;
  the install page's form groups use the same rule; the settings tab's 700px inner wrapper is gone
- „Belépés az alkalmazásba": name + password side by side, fetched on demand (R-254, R-709); pairing in app_login.go
- the empty-value rule: no stored value -> a sentence, never an eye; page and reveal endpoint share storedValuePresent
- „Cím és kulcsok" is rows (no Domain input, no disabled storage select, no install form); hint under the title
- tests red-proved: app_page_tuning_test.go (4); 36 app-page parity fixtures re-captured, 4 new; install fixtures unchanged

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:12:06 +02:00

220 lines
8.6 KiB
Go

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:], "</div>")]
}
// 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)
}
}