package web import ( "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // Indítópult (launcher, v0.163.0). The launcher tile exists ⟺ a "Megnyitás" button would exist: // subdomain presence is the single openability criterion (buildLauncherApps). These tests drive the // pure builder (deterministic — no docker) for the filter/sort/exclusion logic, and the production // launcher template for the rendered tile grammar (hrefs, greyed-stopped, monogram, tile color). // launcherStacks models Scenario A: two openable apps (env-subdomain + meta-subdomain), one protected // app with a well-known subdomain, one deployed app with NO subdomain, and the controller itself with // a subdomain that must still be excluded by name. func launcherStacks() []stacks.Stack { return []stacks.Stack{ {Name: "worker-app", Deployed: true, State: stacks.StateRunning, Meta: stacks.Metadata{Slug: "worker-app", DisplayName: "Worker"}}, // no subdomain → excluded {Name: "mealie", Deployed: true, State: stacks.StateRunning, Meta: stacks.Metadata{Slug: "mealie", DisplayName: "Mealie", Subdomain: "recept"}}, {Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning, Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless"}}, // subdomain via env map {Name: "filebrowser", Protected: true, State: stacks.StateRunning, Meta: stacks.Metadata{Slug: "filebrowser", DisplayName: "FileBrowser"}}, // protectedStackSubdomains {Name: "felhom-controller", Deployed: true, State: stacks.StateRunning, Meta: stacks.Metadata{Slug: "felhom-controller", DisplayName: "Vezérlő", Subdomain: "felhom"}}, } } func launcherSubdomains() map[string]string { return map[string]string{ "mealie": "recept", "paperless-ngx": "papir", "filebrowser": "files", "felhom-controller": "felhom", // present on purpose — must be excluded by name anyway // worker-app: deliberately absent } } // Group A — the builder selects exactly the openable apps, sorted by DisplayName, controller excluded. // COMPANION red-proof (REPORT): drop the `if !ok || sd == ""` continue in buildLauncherApps → // worker-app (no subdomain) leaks in and the "worker absent" assertion FAILS. func TestBuildLauncherApps_SelectionAndOrder(t *testing.T) { apps := buildLauncherApps(launcherStacks(), launcherSubdomains()) var names []string for _, a := range apps { names = append(names, a.Name) } // Openable set = mealie, paperless-ngx, filebrowser — sorted by DisplayName: // FileBrowser < Mealie < Paperless. wantOrder := []string{"filebrowser", "mealie", "paperless-ngx"} if strings.Join(names, ",") != strings.Join(wantOrder, ",") { t.Fatalf("launcher apps = %v, want %v (alphabetical by DisplayName)", names, wantOrder) } for _, a := range apps { if a.Name == "worker-app" { t.Error("worker-app has no subdomain — it must not be launchable") } if a.Name == "felhom-controller" { t.Error("the controller stack must never appear on its own launcher") } } // The subdomain is resolved from the lookup, not the metadata blindly (paperless env=papir). for _, a := range apps { if a.Name == "paperless-ngx" && a.Subdomain != "papir" { t.Errorf("paperless subdomain = %q, want papir (from the lookup)", a.Subdomain) } } } // Group A (render) — each openable tile is an to its public URL with OpenPath and target=_blank. func TestLauncherTemplate_OpenableTiles(t *testing.T) { data := map[string]interface{}{ "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", "Apps": []LauncherApp{ {Name: "mealie", DisplayName: "Mealie", Slug: "mealie", State: stacks.StateRunning, Subdomain: "recept"}, {Name: "gokapi", DisplayName: "Gokapi", Slug: "gokapi", State: stacks.StateRunning, Subdomain: "fajl", OpenPath: "/admin"}, }, } html := renderBackupPage(t, "launcher", data) if !strings.Contains(html, `href="https://recept.demo-felhom.eu"`) { t.Error("mealie tile must link to its public URL") } if !strings.Contains(html, `href="https://fajl.demo-felhom.eu/admin"`) { t.Error("gokapi tile must append OpenPath to its public URL") } if !strings.Contains(html, `target="_blank"`) || !strings.Contains(html, `rel="noopener"`) { t.Error("openable tiles must open in a new tab with rel=noopener") } } // Group B — a stopped app renders a greyed tile with the state badge and NO href (Scenario B). // COMPANION red-proof (REPORT): in launcher.html render the branch unconditionally (drop the // isOperational guard) → the "no href for the stopped tile" assertion FAILS. func TestLauncherTemplate_StoppedTileGreyedNoLink(t *testing.T) { data := map[string]interface{}{ "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", "Apps": []LauncherApp{ {Name: "jellyfin", DisplayName: "Jellyfin", Slug: "jellyfin", State: stacks.StateStopped, Subdomain: "media"}, }, } html := renderBackupPage(t, "launcher", data) if strings.Contains(html, "media.demo-felhom.eu") { t.Error("a stopped app must not be a link (no href to its subdomain)") } if !strings.Contains(html, "launch-cell--off") || !strings.Contains(html, "launch-tile--off") { t.Error("a stopped tile must carry the greyed classes") } if !strings.Contains(html, "Leállítva") { t.Error("a stopped tile must show its Hungarian stateLabel badge") } } // Group C — tile color safety: valid brand passes through, an injection payload becomes a hash HSL // and never appears verbatim in the rendered HTML. // COMPANION red-proof (REPORT): make tileColor return `brand` unvalidated → the payload substring // appears in the rendered style and the "payload absent" assertion FAILS. func TestLauncherTemplate_TileColorSafety(t *testing.T) { const payload = "red;background:url(x)" data := map[string]interface{}{ "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", "Apps": []LauncherApp{ {Name: "jelly", DisplayName: "Jelly", Slug: "jelly", State: stacks.StateRunning, Subdomain: "j", BrandColor: "#00A4DC"}, {Name: "evil", DisplayName: "Evil", Slug: "evil", State: stacks.StateRunning, Subdomain: "e", BrandColor: payload}, }, } html := renderBackupPage(t, "launcher", data) if !strings.Contains(html, "background: #00A4DC") { t.Error("a valid brand_color must pass through verbatim as the tile background") } if strings.Contains(html, payload) { t.Errorf("the injection payload must never reach the rendered HTML, got it in:\n%s", html) } if !strings.Contains(html, "background: hsl(") { t.Error("an invalid brand_color must fall back to the deterministic hash HSL") } } func TestTileColor(t *testing.T) { // Valid overrides pass through verbatim. for _, ok := range []string{"#fff", "#FFF", "#00A4DC", "#00a4dc"} { if got := string(tileColor("slug", ok)); got != ok { t.Errorf("tileColor(_, %q) = %q, want passthrough", ok, got) } } // Invalid overrides fall back to a deterministic HSL derived from the slug only. for _, bad := range []string{"", "red", "#12", "#1234", "red;background:url(x)", "0083D8"} { got := string(tileColor("nextcloud", bad)) if !strings.HasPrefix(got, "hsl(") { t.Errorf("tileColor(_, %q) = %q, want hsl() fallback", bad, got) } } // Determinism: same slug ⇒ same color; different slugs generally differ. if tileColor("nextcloud", "") != tileColor("nextcloud", "") { t.Error("tileColor must be deterministic for a given slug") } if tileColor("nextcloud", "") == tileColor("paperless-ngx", "") { t.Error("distinct slugs collided (acceptable in theory, but these two must not for the fixture)") } } func TestInitial(t *testing.T) { cases := map[string]string{ "Óra": "Ó", // multibyte — must not split the byte "nextcloud": "N", "7days": "7", "": "?", "árvíztűrő": "Á", } for in, want := range cases { if got := initial(in); got != want { t.Errorf("initial(%q) = %q, want %q", in, got, want) } } } // Group E — zero openable apps ⇒ the calm empty state with the /stacks link. func TestLauncherTemplate_EmptyState(t *testing.T) { data := map[string]interface{}{ "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", "Apps": []LauncherApp{}, } html := renderBackupPage(t, "launcher", data) if !strings.Contains(html, "Még nincs telepített alkalmazás.") { t.Error("empty launcher must show the calm empty-state copy") } if !strings.Contains(html, `href="/stacks"`) { t.Error("empty launcher must link to /stacks") } } // End-to-end wiring — GET /launcher through the real ServeHTTP route with disk-seeded stacks: // the route is registered, the handler builds tiles, the nav marks Indítópult active, and a // subdomain-less app never appears. State is not asserted here (docker-dependent); the deterministic // selection/order/state assertions live in the builder + template tests above. func TestLauncherRoute_EndToEnd(t *testing.T) { s := testPageServer(t) stacksDir := s.cfg.Paths.StacksDir // openable: meta subdomain present writeStack(t, stacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true) // not openable: no subdomain anywhere writeStack(t, stacksDir, "worker-app", "display_name: Worker\n", true) if err := s.stackMgr.ScanStacks(); err != nil { t.Logf("ScanStacks (docker status step may fail on a docker-less host): %v", err) } if _, ok := s.stackMgr.GetStack("recept-app"); !ok { t.Fatal("recept-app not discovered by ScanStacks") } rec := getPage(t, s, "/launcher") if rec.Code != 200 { t.Fatalf("GET /launcher = %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Receptek") { t.Error("the openable app must appear on the launcher") } if strings.Contains(body, "Worker") { t.Error("a subdomain-less app must not appear on the launcher") } // Nav: Indítópult present and marked active on this page. if !strings.Contains(body, `href="/launcher" class="active"`) { t.Error("the Indítópult nav entry must be marked active on /launcher") } } // writeStack plants a minimal deployable stack dir (.felhom.yml + compose + app.yaml deployed). func writeStack(t *testing.T, stacksDir, name, meta string, deployed bool) { t.Helper() dir := filepath.Join(stacksDir, name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { t.Fatal(err) } if deployed { if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("deployed: true\nenv: {}\n"), 0o644); err != nil { t.Fatal(err) } } }