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