Files
felhom-controller/controller/internal/web/settings_split_test.go
T
admin cb6f04c8fc D1 Part 3: unified drive view + overlay migration
- storage.html: the registry cards and the separate 'Meghajtók (ügynök
  nézet)' merge into ONE view. Each connected registry card gets an
  #agent-extra-<path> slot; the enrichment JS joins the agent /api/disks
  list on mount path and decorates the matching card in place (role tag
  via i-lock, drive class, durable-id mono line, agent-only
  register/eject/wipe actions). Two extra groups render below:
  'Rendszermeghajtók' (system/backup — read-only, lock tag, NO actions)
  and 'Nem regisztrált meghajtók' (unregistered user-data — register
  action only). Agent-down: one warn note into #agent-warn-note, all
  registry cards still render from server data (graceful degradation).
  The agent-view helpers now emit design-system .tag markup instead of
  .badge (roleTag/classTag/dataTag/regTag/appBackingTag); the 🔒 lock
  emoji is gone (sprite i-lock).
- Overlay migration: every native confirm()/prompt() on the four pages
  routes through a light .confirm-overlay dialog (openDialog; texts
  verbatim) — storage remove forms, netStorageRemove, storageMigrateAll,
  storageDisconnect, storageDecommission (migrate + the type-to-confirm
  anyway branch preserved like-for-like), storageReEnroll; and on the
  system page triggerUpdate + controller/server restart; on the security
  page the two geo Hungary-removal confirms. Scenario F grep: zero
  native confirm/prompt in the four templates.
- Deleted the now-orphaned .badge-lock/.lock-ico CSS (grep-zero first).
- Tests: no-native-confirm scan, agent-down warn-note static assertion;
  integrity gate stays green.
2026-07-02 19:17:19 +02:00

255 lines
8.9 KiB
Go

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")
}
}