Files
TMCandClaude Opus 5.5 60b4c79b0f
CI / build (push) Successful in 40s
Show internal URLs, list label problems, copy a label template
Tiles now show the public URL and, when set, the internal URL as a second
link. Label names ignore case and - versus _. Containers HODA can't fully
use are listed above the tiles with the reason: labels without enable,
a missing url, or an unknown label name with a suggestion (category ->
group). To find labels without enable, discovery reads all containers.

A "Copy labels" button copies every label as a commented template, and
falls back to the copy command where the clipboard API is missing (plain
http on the LAN).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:55:06 +01:00

148 lines
5.3 KiB
Go

package server
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/config"
"git.mariosem.es/msemes/hoda/internal/icons"
)
type fakeIcons map[string]icons.Icon
func (f fakeIcons) URL(id string) string {
if ic, ok := f[id]; ok {
return "/icons/" + id + "?v=" + ic.Version
}
return ""
}
func (f fakeIcons) Lookup(id string) (icons.Icon, bool) {
ic, ok := f[id]
return ic, ok
}
var giteaID = apps.ID("host-1", "gitea")
func newHandler(mode config.Mode) http.Handler {
store := apps.NewStore()
store.SetApps(apps.LocalSource, "host-1", []apps.App{{
ID: giteaID, Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev", State: apps.StateRunning,
Host: "host-1", InternalURL: "http://gitea:3000", Image: "gitea/gitea",
}}, []apps.Issue{{Host: "host-1", Container: "keep", Message: "missing sema.dashboard.url, so it isn't shown"}})
cfg := config.Config{
Mode: mode, NodeName: "host-1", Title: "HODA", LinkTarget: "_blank",
LabelPrefix: "sema.dashboard", PeerToken: "s3cret",
}
ic := fakeIcons{giteaID: {Data: []byte("<svg></svg>"), ContentType: "image/svg+xml", Version: "abc123"}}
return New(cfg, Deps{Store: store, Icons: ic}, "1.0.0", slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func do(h http.Handler, path string, header map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range header {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAPIApps(t *testing.T) {
h := newHandler(config.ModePrimary)
rec := do(h, "/api/apps", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body apps.Response
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Apps) != 1 || body.Apps[0].Icon != "/icons/"+giteaID+"?v=abc123" || body.Node != "host-1" {
t.Errorf("body = %+v", body)
}
if body.Apps[0].InternalURL != "http://gitea:3000" {
t.Errorf("internal URL = %q, want it sent for the tile's second link", body.Apps[0].InternalURL)
}
if len(body.Issues) != 1 || body.Issues[0].Container != "keep" {
t.Errorf("issues = %+v, want the label problem", body.Issues)
}
if strings.Contains(rec.Body.String(), "gitea/gitea") {
t.Errorf("the response leaks the image name: %s", rec.Body)
}
etag := rec.Header().Get("ETag")
if again := do(h, "/api/apps", map[string]string{"If-None-Match": etag}); again.Code != http.StatusNotModified {
t.Errorf("unchanged list: status = %d, want 304", again.Code)
}
}
func TestAgentNeedsToken(t *testing.T) {
h := newHandler(config.ModeAgent)
for _, path := range []string{"/api/apps", "/icons/" + giteaID} {
if rec := do(h, path, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("%s without a token: status = %d, want 401", path, rec.Code)
}
if rec := do(h, path, map[string]string{"Authorization": "Bearer wrong"}); rec.Code != http.StatusUnauthorized {
t.Errorf("%s with a wrong token: status = %d, want 401", path, rec.Code)
}
if rec := do(h, path, map[string]string{"Authorization": "Bearer s3cret"}); rec.Code != http.StatusOK {
t.Errorf("%s with the token: status = %d, want 200", path, rec.Code)
}
}
if rec := do(h, "/healthz", nil); rec.Code != http.StatusOK {
t.Errorf("agent healthz: status = %d, want 200 without a token", rec.Code)
}
if rec := do(h, "/static/app.js", nil); rec.Code != http.StatusNotFound {
t.Errorf("agent static: status = %d, want 404", rec.Code)
}
}
func TestIconHeaders(t *testing.T) {
h := newHandler(config.ModePrimary)
rec := do(h, "/icons/"+giteaID+"?v=abc123", nil)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("status %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
t.Error("an icon is served without the sandbox policy")
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Error("a versioned icon URL is not cached")
}
for _, path := range []string{"/icons/unknown00000", "/icons/..%2f..%2fetc", "/icons/" + strings.Repeat("a", 12)} {
if rec := do(h, path, nil); rec.Code == http.StatusOK {
t.Errorf("%s: status = %d, want no icon", path, rec.Code)
}
}
}
func TestPage(t *testing.T) {
h := newHandler(config.ModePrimary)
rec := do(h, "/", nil)
body := rec.Body.String()
if rec.Code != http.StatusOK || !strings.Contains(body, "<title>HODA</title>") || !strings.Contains(body, `/static/app.js?v=1.0.0`) {
t.Errorf("page: status %d\n%s", rec.Code, body)
}
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "script-src 'self'") {
t.Error("the page is served without a content security policy")
}
for _, path := range []string{"/static/app.js", "/static/app.css", "/static/favicon.svg"} {
if rec := do(h, path, nil); rec.Code != http.StatusOK {
t.Errorf("%s: status = %d", path, rec.Code)
}
}
if rec := do(h, "/static/app.js?v=1.0.0", nil); !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Error("versioned static assets are not cached")
}
if rec := do(h, "/nope", nil); rec.Code != http.StatusNotFound {
t.Errorf("/nope: status = %d, want 404", rec.Code)
}
}