c0f3e12483
statfs fsclass helper (network/autofs/stub/unknown, fail-open); probe not_network_fs assertion (stub can never verify — red-proven); deploy-time stub refusal (idle autofs proceeds — red-proven); distinct stub badge, stub wins over unreachable (unreachable line byte-identical); deployed select shows stored HDD_PATH (red-proven vs IsDefault-only). MinAgent unchanged 0.81.0. Gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
223 lines
8.7 KiB
Go
223 lines
8.7 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 := os.ReadFile(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
|
|
}
|
|
|
|
// optionTag returns the full <option ...>...</option> block whose value attribute equals path.
|
|
func optionTag(t *testing.T, body, path string) string {
|
|
t.Helper()
|
|
marker := `value="` + path + `"`
|
|
i := strings.Index(body, marker)
|
|
if i < 0 {
|
|
t.Fatalf("no option with value %q in rendered page", path)
|
|
}
|
|
start := strings.LastIndex(body[:i], "<option")
|
|
end := strings.Index(body[i:], ">")
|
|
return body[start : i+end]
|
|
}
|
|
|
|
// Deployed on the NAS → the NAS option carries `selected`; the default drive does NOT.
|
|
// Companion red-proof: revert deploy.html to the IsDefault-only selection → the default option is
|
|
// selected instead → both assertions FAIL (the exact S-C lie from the RCA).
|
|
func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) {
|
|
s := testDeployPageServer(t, "/mnt/felhom-drives/nas-media")
|
|
rec := getPage(t, s, "/stacks/testapp/deploy")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET deploy page = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if nas := optionTag(t, body, "/mnt/felhom-drives/nas-media"); !strings.Contains(nas, "selected") {
|
|
t.Errorf("the STORED path's option must be selected, got: %s", nas)
|
|
}
|
|
if usb := optionTag(t, body, "/mnt/felhom-drives/felhom-usb"); strings.Contains(usb, "selected") {
|
|
t.Errorf("the default drive must NOT be selected for a deployed app, got: %s", usb)
|
|
}
|
|
}
|
|
|
|
// Stored path absent from the schedulable list → an extra disabled option names it verbatim (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, "/stacks/testapp/deploy")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET deploy page = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "/mnt/felhom-drives/gone (nem elérhető)") {
|
|
t.Errorf("missing stored path must render as a disabled truth option")
|
|
}
|
|
if gone := optionTag(t, body, "/mnt/felhom-drives/gone"); !strings.Contains(gone, "selected") || !strings.Contains(gone, "disabled") {
|
|
t.Errorf("the truth option must be selected+disabled, got: %s", gone)
|
|
}
|
|
}
|