package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "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" ) // testPageServer builds a Server complete enough to render full pages through ServeHTTP // (templates loaded, settings + stack manager real, agent/hub absent). func testPageServer(t *testing.T) *Server { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Customer.ID = "test-customer" cfg.Customer.Name = "Teszt Ügyfél" cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Stacks.ComposeCommand = "docker compose" // skip detection (not needed for page renders) sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatalf("settings: %v", err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatalf("stacks manager: %v", err) } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() return s } func getPage(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, path, nil) s.ServeHTTP(rec, req) return rec } // TestSettingsSplitPagesRender (D1 Scenario A): each page responds 200, carries its own // sections, and does NOT carry another page's sections (no cross-leak). func TestSettingsSplitPagesRender(t *testing.T) { s := testPageServer(t) cases := []struct { path string must []string mustNot []string }{ {"/settings", []string{"Rendszer konfiguráció", "Verzió és frissítés", "Vezérlő újraindítása", "Kiszolgáló újraindítása"}, []string{"Adattárolók", "Jelszó módosítás", "Értesítési szünet"}}, {"/settings/notifications", []string{"Beállítások — Értesítések"}, []string{"Rendszer konfiguráció", "Adattárolók", "Jelszó módosítás"}}, {"/settings/security", []string{"Jelszó módosítás", "Földrajzi korlátozás"}, []string{"Rendszer konfiguráció", "Adattárolók", "Értesítési szünet"}}, {"/storage", []string{"Adattárolók", "Hálózati tárhely (NAS)"}, []string{"Rendszer konfiguráció", "Jelszó módosítás", "Értesítési szünet"}}, } for _, c := range cases { rec := getPage(t, s, c.path) if rec.Code != 200 { t.Errorf("GET %s = %d, want 200", c.path, rec.Code) continue } body := rec.Body.String() for _, m := range c.must { if !strings.Contains(body, m) { t.Errorf("GET %s: missing section %q", c.path, m) } } for _, m := range c.mustNot { if strings.Contains(body, m) { t.Errorf("GET %s: leaked foreign section %q", c.path, m) } } } } // TestSettingsSectionInventory (D1 §10): every h3 section of the pre-split settings.html is // accounted for in the UNION of the four split templates (one deliberate rename noted). func TestSettingsSectionInventory(t *testing.T) { oldHeadings := []string{ "Rendszer konfiguráció", "Verzió és frissítés", "Adattárolók", "Hálózati tárhely (NAS)", "Földrajzi korlátozás", "Jelszó módosítás", "Értesítések", "Alkalmazás-email", "Vészhelyzeti információk", // renamed from the misspelled "Veszhelyzeti informaciok" "Vezérlő újraindítása", "Kiszolgáló újraindítása", } var union strings.Builder for _, f := range []string{"settings_system.html", "settings_notifications.html", "settings_security.html", "storage.html"} { b, err := templateFS.ReadFile("templates/" + f) if err != nil { t.Fatalf("read %s: %v", f, err) } union.Write(b) } u := union.String() for _, h := range oldHeadings { if !strings.Contains(u, h) { t.Errorf("old settings section %q missing from the union of the split templates", h) } } if strings.Contains(u, "Veszhelyzeti informaciok") { t.Error("the misspelled heading survived the split") } } // TestWizardRoutesMovedWith301 (D1 Scenario E): old wizard URLs permanently redirect. func TestWizardRoutesMovedWith301(t *testing.T) { s := testPageServer(t) cases := map[string]string{ "/settings/storage/init": "/storage/init", "/settings/storage/attach": "/storage/attach", } for old, want := range cases { rec := getPage(t, s, old) if rec.Code != http.StatusMovedPermanently { t.Errorf("GET %s = %d, want 301", old, rec.Code) } if loc := rec.Header().Get("Location"); loc != want { t.Errorf("GET %s Location = %q, want %q", old, loc, want) } } // and the new URLs render for _, p := range []string{"/storage/init", "/storage/attach"} { if rec := getPage(t, s, p); rec.Code != 200 { t.Errorf("GET %s = %d, want 200", p, rec.Code) } } } // TestStorageActionRedirectsToStorage (D1 Scenario D): storage POST successes land on // /storage?storage_msg=..., and the flash renders there. func TestStorageActionRedirectsToStorage(t *testing.T) { s := testPageServer(t) if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/test-drive", Label: "Teszt", Schedulable: true}); err != nil { t.Fatalf("add path: %v", err) } form := url.Values{"storage_path": {"/mnt/test-drive"}, "storage_label": {"Új Név"}} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/settings/storage/label", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.settingsStorageLabelHandler(rec, req) if rec.Code != http.StatusFound { t.Fatalf("label POST = %d, want 302", rec.Code) } loc := rec.Header().Get("Location") if !strings.HasPrefix(loc, "/storage?storage_msg=success") { t.Errorf("Location = %q, want prefix /storage?storage_msg=success", loc) } // The flash renders on /storage rec2 := getPage(t, s, loc) if rec2.Code != 200 { t.Fatalf("GET %s = %d, want 200", loc, rec2.Code) } if !strings.Contains(rec2.Body.String(), "Megnevezés módosítva") { t.Errorf("/storage did not render the storage flash message") } } // TestPasswordErrorRerendersSecurityPage (D1 Scenario D): a wrong current password // re-renders the page carrying the password form with the inline error. func TestPasswordErrorRerendersSecurityPage(t *testing.T) { s := testPageServer(t) // enable auth so the password form path is active if err := s.settings.SetPasswordHash("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"); err != nil { // "password" t.Fatalf("set hash: %v", err) } form := url.Values{ "current_password": {"wrong-password"}, "new_password": {"newpassword123"}, "confirm_password": {"newpassword123"}, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/settings/password", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.settingsPasswordHandler(rec, req) if rec.Code != 200 { t.Fatalf("password POST = %d, want 200 (inline error re-render)", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "Hibás jelenlegi jelszó") { t.Error("missing inline error 'Hibás jelenlegi jelszó'") } if !strings.Contains(body, "Jelszó módosítás") { t.Error("re-rendered page lacks the password form section") } } // TestStorageNoNativeConfirm (D1 Scenario F): the four split templates contain no native // confirm()/prompt() — all consequential actions route through the overlay. func TestStorageNoNativeConfirm(t *testing.T) { nativeRe := regexp.MustCompile(`(^|[^A-Za-z_.])(confirm|prompt)\(`) // allow the pre-existing type-to-confirm overlay helper names allow := regexp.MustCompile(`openConfirm|__closeConfirm|confirmEject|confirmWipe|typeToConfirm|confirm-`) for _, f := range []string{"storage.html", "settings_system.html", "settings_notifications.html", "settings_security.html"} { b, err := templateFS.ReadFile("templates/" + f) if err != nil { t.Fatalf("read %s: %v", f, err) } for i, line := range strings.Split(string(b), "\n") { if nativeRe.MatchString(line) && !allow.MatchString(line) { t.Errorf("%s:%d native confirm/prompt: %s", f, i+1, strings.TrimSpace(line)) } } } } // TestStorageAgentDownNote (D1 Scenario C, static): the enrichment JS has an error path that // renders the exact warn note into #agent-warn-note (graceful degradation when the agent is down). func TestStorageAgentDownNote(t *testing.T) { b, err := templateFS.ReadFile("templates/storage.html") if err != nil { t.Fatal(err) } body := string(b) if !strings.Contains(body, `id="agent-warn-note"`) { t.Error("missing #agent-warn-note element") } if !strings.Contains(body, "Az ügynök nem elérhető") { t.Error("missing the agent-unreachable warn text") } // the catch block must target the note element if !strings.Contains(body, "warn.innerHTML=") { t.Error("enrichment JS lacks the warn-note error path") } }