Files
HODA/internal/apps/app_test.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

147 lines
5.3 KiB
Go

package apps
import (
"encoding/json"
"strings"
"testing"
"git.mariosem.es/msemes/hoda/internal/docker"
)
const prefix = "sema.dashboard"
func container(state, status string, labels map[string]string) docker.Container {
return docker.Container{
ID: "4f1c2d3e4f5a6b7c",
Names: []string{"/gitea"},
Image: "gitea/gitea:1.22",
State: state,
Status: status,
Labels: labels,
}
}
func TestFromContainer(t *testing.T) {
full := map[string]string{
"sema.dashboard.enable": "true",
"sema.dashboard.name": "Gitea",
"sema.dashboard.url": "https://git.mariosem.es",
"sema.dashboard.group": "Dev",
"sema.dashboard.description": "Git hosting and CI",
"sema.dashboard.order": "10",
"sema.dashboard.icon": "gitea",
"sema.dashboard.internal_url": "http://gitea:3000",
}
app, ok, err := FromContainer(container("running", "Up 2 hours (healthy)", full), prefix, "host-1")
if !ok || err != nil {
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
}
want := App{
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev",
Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
IconHint: "gitea", InternalURL: "http://gitea:3000", Image: "gitea/gitea:1.22",
}
if app != want {
t.Errorf("FromContainer =\n%+v\nwant\n%+v", app, want)
}
}
func TestFromContainerDefaults(t *testing.T) {
labels := map[string]string{
"sema.dashboard.enable": "TRUE",
"sema.dashboard.url": "http://192.168.1.10:8096",
"com.docker.compose.service": "jellyfin",
}
app, ok, err := FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
if !ok || err != nil {
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
}
if app.Name != "jellyfin" || app.Group != DefaultGroup || app.Order != 0 {
t.Errorf("defaults = name %q, group %q, order %d; want jellyfin, %s, 0", app.Name, app.Group, app.Order, DefaultGroup)
}
delete(labels, "com.docker.compose.service")
app, _, _ = FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
if app.Name != "gitea" {
t.Errorf("name without a compose service = %q, want the container name gitea", app.Name)
}
}
func TestFromContainerRejects(t *testing.T) {
tests := []struct {
name string
labels map[string]string
wantOK bool
wantErr string
}{
{"not opted in", map[string]string{"sema.dashboard.url": "https://x.example"}, false, ""},
{"enable false", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.url": "https://x.example"}, false, ""},
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, true, "missing sema.dashboard.url"},
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, true, "not an http or https URL"},
{"relative url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "/gitea"}, true, "not an http or https URL"},
{"bad internal url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.internal_url": "gitea:3000"}, true, "internal_url"},
{"bad order", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.order": "first"}, true, "not a whole number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok, err := FromContainer(container("running", "Up", tt.labels), prefix, "host-1")
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if tt.wantErr == "" && err != nil {
t.Errorf("err = %v, want none", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Errorf("err = %v, want one containing %q", err, tt.wantErr)
}
})
}
}
func TestState(t *testing.T) {
tests := []struct {
state, status string
want State
}{
{"running", "Up 3 hours", StateRunning},
{"running", "Up 3 hours (healthy)", StateRunning},
{"running", "Up 2 minutes (health: starting)", StateRunning},
{"running", "Up 3 hours (unhealthy)", StateUnhealthy},
{"restarting", "Restarting (1) 5 seconds ago", StateUnhealthy},
{"exited", "Exited (0) 2 days ago", StateStopped},
{"paused", "Up 3 hours (Paused)", StateStopped},
{"created", "Created", StateStopped},
}
for _, tt := range tests {
if got := stateOf(container(tt.state, tt.status, nil)); got != tt.want {
t.Errorf("state of %s / %q = %s, want %s", tt.state, tt.status, got, tt.want)
}
}
}
func TestID(t *testing.T) {
a, b := ID("host-1", "gitea"), ID("host-2", "gitea")
if a == b {
t.Error("the same container name on two hosts got the same ID")
}
if a != ID("host-1", "gitea") {
t.Error("ID is not stable")
}
if !ValidID(a) || ValidID("../etc/passwd") || ValidID("ABCDEF123456") {
t.Error("ValidID accepts or rejects the wrong IDs")
}
}
func TestAppJSONHidesInternalFields(t *testing.T) {
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea"}
raw, err := json.Marshal(app)
if err != nil {
t.Fatal(err)
}
for _, leak := range []string{"gitea:3000", "gitea/gitea", "Hint", "Internal", "Image"} {
if strings.Contains(string(raw), leak) {
t.Errorf("JSON %s contains %q", raw, leak)
}
}
}