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