CI / build (push) Successful in 40s
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>
148 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|