Files
memby/server/internal/api/admin_test.go
T

488 lines
16 KiB
Go
Raw Normal View History

package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
2026-08-02 22:10:19 +12:00
"slices"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
2026-08-02 22:10:19 +12:00
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func testServer(cfg config.Config) *Server {
return New(cfg, Deps{Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
}
func TestMaintenanceGatePassesTrafficWhenOnline(t *testing.T) {
server := testServer(config.Config{})
var reached bool
handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
if !reached || rec.Code != http.StatusOK {
t.Fatalf("request should have passed through, got %d", rec.Code)
}
}
func TestMaintenanceGateBlocksWithTheOperatorsMessage(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back at 9pm"})
handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler must not run while offline")
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected a Retry-After header")
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body: %v", err)
}
// The TV keys off `maintenance` to tell "we turned it off" from "the network died".
if body["maintenance"] != true {
t.Fatalf("expected maintenance:true, got %v", body)
}
if body["message"] != "Back at 9pm" {
t.Fatalf("operator message not surfaced: %v", body["message"])
}
}
func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true})
rec := httptest.NewRecorder()
server.maintenanceGate(http.NotFoundHandler()).
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["message"] != store.DefaultMaintenanceMessage {
t.Fatalf("expected the default message, got %v", body["message"])
}
}
func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
// Health checks and the admin page sit outside the gate on purpose: they are what
// you need most while the app is deliberately down.
2026-08-02 22:10:19 +12:00
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
server.maintenance.set(store.Maintenance{Enabled: true})
rec := httptest.NewRecorder()
server.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("healthz should stay 200 during maintenance, got %d", rec.Code)
}
rec = httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
2026-08-02 22:10:19 +12:00
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/features" {
t.Fatalf("admin root should stay reachable via library redirect, got %d %q",
rec.Code, rec.Header().Get("Location"))
}
}
2026-07-27 21:06:51 +12:00
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
rec := httptest.NewRecorder()
server.handleServiceStatus(
rec,
httptest.NewRequest(http.MethodGet, "/v1/status", nil),
store.Session{},
)
if rec.Code != http.StatusOK {
t.Fatalf("status endpoint returned %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["maintenance"] != true || body["message"] != "Back after dinner" {
t.Fatalf("unexpected status response: %v", body)
}
}
2026-07-29 15:26:27 +12:00
func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Version", "0.9.1")
req.Header.Set("X-Memby-Protocol", "99")
rec := httptest.NewRecorder()
server.handleServiceStatus(rec, req, store.Session{})
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["compatible"] != false || body["compatibilityMessage"] == "" {
t.Fatalf("mismatch was not explicit: %v", body)
}
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(membyProtocolVersion) {
t.Fatalf("version diagnostics missing: %v", body)
}
}
func TestServiceStatusAcceptsCurrentProtocol(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Protocol", "1")
rec := httptest.NewRecorder()
server.handleServiceStatus(rec, req, store.Session{})
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["compatible"] != true || body["compatibilityMessage"] != "" {
t.Fatalf("current protocol should be compatible: %v", body)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
2026-08-03 10:16:44 +12:00
for _, path := range []string{"/admin/", "/admin/api/status", "/admin/api/accounts"} {
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("%s should 404 when no admin token is configured, got %d", path, rec.Code)
}
}
}
2026-08-02 22:10:19 +12:00
func TestPlaybackPolicyRejectsUnsafeDuration(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodPost, "/admin/api/playback-policy",
strings.NewReader(`{"prerollEnabled":true,"prerollDurationMs":500}`))
rec := httptest.NewRecorder()
server.handleAdminPlaybackPolicy(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestAdminAuthRejectsAWrongToken(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
cases := map[string]string{
"missing": "",
"wrong": "Bearer nope",
"prefix": "Bearer secretish",
}
for name, header := range cases {
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s token should be rejected, got %d", name, rec.Code)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the correct token should be accepted, got %d", rec.Code)
}
}
2026-08-02 22:10:19 +12:00
func TestAdminRuntimeMetricsAreProtectedAndReportHeap(t *testing.T) {
2026-07-29 15:26:27 +12:00
server := testServer(config.Config{AdminToken: "secret"})
2026-08-02 22:10:19 +12:00
unauthorized := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(
unauthorized,
httptest.NewRequest(http.MethodGet, "/admin/api/runtime", nil),
)
if unauthorized.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized runtime status = %d", unauthorized.Code)
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/runtime", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("runtime status = %d: %s", rec.Code, rec.Body.String())
}
var body adminRuntimeStatus
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Goroutines < 1 || body.HeapInuse == 0 || body.MemoryLimit <= 0 {
t.Fatalf("runtime metrics = %+v", body)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("cache control = %q", rec.Header().Get("Cache-Control"))
}
}
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "https://memby.local/admin/library", nil)
addInstallerSession(t, server, req)
2026-07-29 15:26:27 +12:00
rec := httptest.NewRecorder()
2026-08-02 22:10:19 +12:00
server.adminRoutes().ServeHTTP(rec, req)
2026-07-29 15:26:27 +12:00
result := rec.Result()
cookies := result.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one admin cookie, got %d", len(cookies))
}
cookie := cookies[0]
if cookie.Name != adminCookieName || cookie.Value != "secret" {
t.Fatalf("unexpected admin cookie: %#v", cookie)
}
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("admin cookie is not hardened: %#v", cookie)
}
if cookie.MaxAge <= 0 || cookie.Path != "/admin" {
t.Fatalf("admin cookie is not persistent or scoped: %#v", cookie)
}
}
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
2026-08-02 22:10:19 +12:00
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the admin cookie should be accepted, got %d", rec.Code)
}
}
func TestAdminPageRequiresDiscreetEmbyGate(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("admin gate status = %d", rec.Code)
}
body := strings.ToLower(rec.Body.String())
for _, forbidden := range []string{"memby", "emby", "installer", "administration", "analytics"} {
if strings.Contains(body, forbidden) {
t.Fatalf("admin gate disclosed %q before login", forbidden)
}
}
if !strings.Contains(body, "login required to continue.") ||
!strings.Contains(body, `name="next" type="hidden" value="/admin/logs"`) {
t.Fatalf("admin gate has wrong copy or return destination: %s", rec.Body.String())
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("admin gate issued an admin cookie before Emby authentication")
}
}
func TestAdminPagesUseRealRoutes(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
for _, page := range []string{
2026-08-03 10:16:44 +12:00
"accounts", "library", "recommendations", "requests", "ratings", "maintenance",
2026-08-02 22:10:19 +12:00
"updates", "engagement", "imports", "logs",
} {
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/admin/%s = %d", page, rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `href="/admin/logs"`) ||
!strings.Contains(body, `data-admin-page="`+page+`"`) {
t.Fatalf("/admin/%s does not contain routed navigation/page marker", page)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/not-a-page", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown admin page = %d", rec.Code)
}
}
2026-08-03 10:16:44 +12:00
func TestAccountsPageDistinguishesMembyFromEmbyAndProvidesManagement(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/accounts", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
body := rec.Body.String()
for _, wanted := range []string{
"This is the Memby account list, not the Emby user directory",
"Signed-in devices", "Recommendation prompt", "Remove Memby access",
"Clear stored choices",
} {
if !strings.Contains(body, wanted) {
t.Fatalf("accounts page does not contain %q", wanted)
}
}
}
2026-08-03 08:52:55 +12:00
func TestMDBListAdminStatusNeverExposesTheAPIKey(t *testing.T) {
view := publicMDBListSettings(store.MDBListSettings{
Enabled: true, APIKey: "super-secret", Sources: []string{"imdb"},
})
body, err := json.Marshal(view)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "super-secret") || !strings.Contains(string(body), `"apiKeyConfigured":true`) {
t.Fatalf("unsafe MDBList admin payload: %s", body)
}
}
2026-08-02 22:10:19 +12:00
func TestInstallerDestinationAllowsOnlyKnownAdminPages(t *testing.T) {
if got := cleanInstallerDestination("/admin/recommendations"); got != "/admin/recommendations" {
t.Fatalf("recommendation destination = %q", got)
}
for _, unsafe := range []string{
"/admin/not-real", "/admin/../updates/latest.apk", "/admin/logs?next=https://evil.test",
} {
if got := cleanInstallerDestination(unsafe); got != "/install" {
t.Fatalf("unsafe destination %q accepted as %q", unsafe, got)
}
}
}
func TestAdminEligibleRowsExplainPlacement(t *testing.T) {
item := store.PreparedForYouItem{
RuntimeMinutes: 28, CompatibilityScore: 0.8,
ReasonKind: "completed-title", ReasonGenre: "Drama",
ReasonSourceTitle: "Arrival",
}
rows := adminEligibleRows(item, recommend.Item{Type: "Series"})
for _, wanted := range []string{
"Top picks for you", "Because you finished Arrival", "More Drama for you",
"Plays well on this TV", "One episode before bed", "Hidden in your library",
} {
if !slices.Contains(rows, wanted) {
t.Fatalf("eligible rows %v missing %q", rows, wanted)
}
}
}
func TestAdminCookieCannotOutliveEmbyGate(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
2026-07-29 15:26:27 +12:00
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
2026-08-02 22:10:19 +12:00
if rec.Code != http.StatusUnauthorized {
t.Fatalf("admin cookie without Emby gate got %d, want 401", rec.Code)
2026-07-29 15:26:27 +12:00
}
}
func TestToRowEventValidatesAndClamps(t *testing.T) {
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
t.Run("rejects unknown event kinds", func(t *testing.T) {
if _, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "scrolled"}, "u", now); ok {
t.Fatal("unknown event kind should be dropped")
}
})
t.Run("rejects events with no row", func(t *testing.T) {
if _, ok := toRowEvent(rowEventPayload{Event: "focus"}, "u", now); ok {
t.Fatal("an event with no row id should be dropped")
}
})
t.Run("clamps implausible dwell", func(t *testing.T) {
event, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: 99 * 60 * 60 * 1000}, "u", now)
if !ok {
t.Fatal("event should be accepted")
}
if event.DwellMs != maxDwellMs {
t.Fatalf("dwell = %d, want clamped to %d", event.DwellMs, maxDwellMs)
}
event, _ = toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: -5}, "u", now)
if event.DwellMs != 0 {
t.Fatalf("negative dwell should floor at 0, got %d", event.DwellMs)
}
})
t.Run("ignores a device clock that is wildly wrong", func(t *testing.T) {
event, _ := toRowEvent(
rowEventPayload{RowID: "r", Event: "impression", OccurredAt: "1970-01-01T00:00:00Z"}, "u", now)
if !event.OccurredAt.Equal(now) {
t.Fatalf("expected the server clock to win, got %v", event.OccurredAt)
}
})
t.Run("accepts a plausible device timestamp", func(t *testing.T) {
earlier := now.Add(-30 * time.Second).Format(time.RFC3339)
event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "select", OccurredAt: earlier}, "u", now)
if event.OccurredAt.Equal(now) {
t.Fatal("a recent device timestamp should be kept")
}
})
t.Run("stamps the session's user", func(t *testing.T) {
event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "focus"}, "user-9", now)
if event.UserID != "user-9" {
t.Fatalf("user should come from the session, got %q", event.UserID)
}
})
}