987e915bf2
New /launcher page: a grid of large tappable tiles, one per openable deployed app (subdomain presence is the single openability criterion, shared with the Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles (deterministic slug color or .felhom.yml brand_color), white glyph/monogram fallback, target=_blank links for operational apps, greyed unclickable tiles for stopped ones. First sidebar item; / stays the Vezérlőpult. Universal app placeholder: new AppPlaceholderSVG served at /static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a logo-less app shows a placeholder instead of visibility:hidden. Brand mark is never an app placeholder. New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions + 4 red-proofs. No agent coupling; MinAgent unchanged.
254 lines
11 KiB
Go
254 lines
11 KiB
Go
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 <a> 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 <a> 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)
|
|
}
|
|
}
|
|
}
|