Files
HODA/internal/peer/peer_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

95 lines
2.7 KiB
Go

package peer
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
)
var (
immichID = apps.ID("host-2", "immich")
quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
)
func agent(t *testing.T, iconHits *atomic.Int32) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer s3cret" {
http.Error(w, "no", http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/api/apps":
_ = json.NewEncoder(w).Encode(apps.Response{
Node: "host-2",
Hosts: []apps.HostStatus{{Name: "host-2", OK: true}},
Apps: []apps.App{
{ID: immichID, Name: "Immich", URL: "https://photos.mariosem.es", Group: "Media", Host: "host-2", Icon: "/icons/" + immichID + "?v=v1"},
{ID: apps.ID("host-2", "evil"), Name: "Evil", URL: "javascript:alert(1)", Host: "host-2"},
{ID: "../../etc", Name: "Bad ID", URL: "https://x.example", Host: "host-2"},
},
})
case "/icons/" + immichID:
iconHits.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("png"))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestPull(t *testing.T) {
var hits atomic.Int32
srv := agent(t, &hits)
store := apps.NewStore()
p := New([]string{srv.URL}, "s3cret", time.Second, store, quiet)
p.pull(context.Background(), srv.URL)
hosts, list := store.Snapshot(nil)
if len(list) != 1 || list[0].Name != "Immich" {
t.Fatalf("apps = %+v, want only the valid app", list)
}
if len(hosts) != 1 || !hosts[0].OK || hosts[0].Name != "host-2" {
t.Errorf("hosts = %+v", hosts)
}
ic, ok := p.Icon(context.Background(), immichID)
if !ok || string(ic.Data) != "png" || ic.Version != "v1" {
t.Fatalf("Icon = %+v, %v", ic, ok)
}
p.Icon(context.Background(), immichID)
if hits.Load() != 1 {
t.Errorf("icon fetched %d times, want once per version", hits.Load())
}
srv.Close()
p.pull(context.Background(), srv.URL)
hosts, list = store.Snapshot(nil)
if len(list) != 1 || hosts[0].OK || hosts[0].Name != "host-2" || hosts[0].LastOK == nil {
t.Errorf("after the agent went away: hosts %+v, %d apps; want it unreachable with its apps kept", hosts, len(list))
}
}
func TestWrongToken(t *testing.T) {
var hits atomic.Int32
srv := agent(t, &hits)
store := apps.NewStore()
p := New([]string{srv.URL}, "wrong", time.Second, store, quiet)
p.pull(context.Background(), srv.URL)
hosts, list := store.Snapshot(nil)
if len(list) != 0 || len(hosts) != 1 || hosts[0].OK || hosts[0].Name != "127.0.0.1" {
t.Errorf("hosts %+v, apps %+v; want the agent unreachable under its address", hosts, list)
}
}