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>
164 lines
6.4 KiB
Go
164 lines
6.4 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 parse(labels map[string]string) (App, bool, []string) {
|
|
return Parse(container("running", "Up 2 hours (healthy)", labels), prefix, "host-1")
|
|
}
|
|
|
|
func TestParse(t *testing.T) {
|
|
app, show, problems := parse(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",
|
|
})
|
|
if !show || len(problems) != 0 {
|
|
t.Fatalf("show %v, problems %q; want a clean app", show, problems)
|
|
}
|
|
want := App{
|
|
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000",
|
|
Group: "Dev", Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
|
|
IconHint: "gitea", Image: "gitea/gitea:1.22",
|
|
}
|
|
if app != want {
|
|
t.Errorf("Parse =\n%+v\nwant\n%+v", app, want)
|
|
}
|
|
}
|
|
|
|
func TestParseIgnoresCaseAndDashes(t *testing.T) {
|
|
app, show, problems := parse(map[string]string{
|
|
"Sema.Dashboard.Enable": "True",
|
|
"sema.dashboard.URL": "https://git.mariosem.es",
|
|
"sema.dashboard.Group": "Dev",
|
|
"sema.dashboard.internal-url": "http://10.20.30.10:3000",
|
|
})
|
|
if !show || len(problems) != 0 || app.Group != "Dev" || app.InternalURL != "http://10.20.30.10:3000" {
|
|
t.Errorf("show %v, problems %q, app %+v", show, problems, app)
|
|
}
|
|
}
|
|
|
|
func TestParseDefaults(t *testing.T) {
|
|
labels := map[string]string{
|
|
"sema.dashboard.enable": "1",
|
|
"sema.dashboard.url": "http://192.168.1.10:8096",
|
|
"com.docker.compose.service": "jellyfin",
|
|
}
|
|
app, _, _ := parse(labels)
|
|
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")
|
|
if app, _, _ = parse(labels); app.Name != "gitea" {
|
|
t.Errorf("name without a compose service = %q, want the container name gitea", app.Name)
|
|
}
|
|
}
|
|
|
|
func TestParseProblems(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
labels map[string]string
|
|
wantShow bool
|
|
want string // a word every problem list must contain; "" = no problems
|
|
}{
|
|
{"no labels", map[string]string{"com.docker.compose.service": "x"}, false, ""},
|
|
{"hidden on purpose", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.category": "x"}, false, ""},
|
|
{"no enable", map[string]string{"sema.dashboard.url": "https://x.example", "sema.dashboard.group": "Dev"}, false, "no sema.dashboard.enable"},
|
|
{"enable yes", map[string]string{"sema.dashboard.enable": "yes", "sema.dashboard.url": "https://x.example"}, false, `use "true"`},
|
|
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, false, "missing sema.dashboard.url"},
|
|
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, false, "needs http"},
|
|
{"lookalike url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.remote_url": "https://x.example"}, false, "did you mean sema.dashboard.url?"},
|
|
{"lookalike group", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.category": "Media"}, true, "did you mean sema.dashboard.group?"},
|
|
{"unknown label", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.colour": "red"}, true, "is ignored"},
|
|
{"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) {
|
|
_, show, problems := parse(tt.labels)
|
|
if show != tt.wantShow {
|
|
t.Errorf("show = %v, want %v", show, tt.wantShow)
|
|
}
|
|
if tt.want == "" && len(problems) != 0 {
|
|
t.Errorf("problems = %q, want none", problems)
|
|
}
|
|
if tt.want != "" && !strings.Contains(strings.Join(problems, "\n"), tt.want) {
|
|
t.Errorf("problems = %q, want one containing %q", problems, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 TestAppJSON(t *testing.T) {
|
|
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea-hint"}
|
|
raw, err := json.Marshal(app)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(raw), `"internalUrl":"http://gitea:3000"`) {
|
|
t.Errorf("JSON %s lacks the internal URL", raw)
|
|
}
|
|
for _, leak := range []string{"gitea/gitea", "gitea-hint", "Image", "Hint"} {
|
|
if strings.Contains(string(raw), leak) {
|
|
t.Errorf("JSON %s contains %q", raw, leak)
|
|
}
|
|
}
|
|
}
|