d3c97c62fe
User feedback on D1: the NAS-add button and the local-drive enrollment buttons sat side by side with no separation — two different storage classes interleaved on one page. Now two subpages under Tárhely: - /storage — Tárhely — Meghajtók: physical drive registry, migrate, wizard entry points (Új meghajtó inicializálása / Meglévő meghajtó csatolása), unified agent view, manual add. The enrollment buttons now live unambiguously in the local-drive context. - /storage/network — Tárhely — Hálózati tárhely (NAS): the NAS share list (NAS-megosztások) + add form + its JS (moved verbatim, incl. its own openDialog copy for the remove overlay). - layout.html: Tárhely main-nav item gains two always-visible nested sub-links (Meghajtók / Hálózati tárhely, .nav-links-nested CSS); parent stays highlighted on both. - handlers.go: NetworkStoragePaths moves out of storagePageData into the new networkStoragePageData (page key storage-network) + storageNetworkPageHandler; GET /storage/network route. - Tests: /storage must NOT render the NAS section, /storage/network renders it and nothing drive-related; inventory + no-native-confirm scans cover the new template. Both template gates green.
296 lines
10 KiB
Go
296 lines
10 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", "Tárhely — Meghajtók"},
|
|
[]string{"Rendszer konfiguráció", "Jelszó módosítás", "Értesítési szünet", "NAS-megosztások"}},
|
|
{"/storage/network",
|
|
[]string{"Tárhely — Hálózati tárhely (NAS)", "NAS-megosztások"},
|
|
[]string{"Adattárolók", "Rendszer konfiguráció", "Jelszó módosítás"}},
|
|
}
|
|
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", "storage_network.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", "storage_network.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")
|
|
}
|
|
}
|
|
|
|
// TestNoEmojiInTemplates (D1 §10, Group G): no emoji/pictographs in web templates. Go-side
|
|
// codepoint scan (the D0 grep-based gate false-negatived multibyte emoji on Windows).
|
|
func TestNoEmojiInTemplates(t *testing.T) {
|
|
allow := map[rune]bool{}
|
|
for _, r := range "✓✗✔✘•●○■▶" {
|
|
allow[r] = true
|
|
}
|
|
isEmoji := func(r rune) bool {
|
|
if allow[r] {
|
|
return false
|
|
}
|
|
switch {
|
|
case r >= 0x1F300 && r <= 0x1FAFF,
|
|
r >= 0x2600 && r <= 0x26FF,
|
|
r >= 0x2700 && r <= 0x27BF,
|
|
r >= 0xFE00 && r <= 0xFE0F,
|
|
r >= 0x1F1E6 && r <= 0x1F1FF:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
entries, _ := templateFS.ReadDir("templates")
|
|
for _, e := range entries {
|
|
if !strings.HasSuffix(e.Name(), ".html") {
|
|
continue
|
|
}
|
|
b, err := templateFS.ReadFile("templates/" + e.Name())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range string(b) {
|
|
if isEmoji(r) {
|
|
t.Errorf("%s contains emoji %q (U+%04X)", e.Name(), r, r)
|
|
}
|
|
}
|
|
}
|
|
}
|