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>
95 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|