package api import ( "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "slices" "strings" "testing" "time" "github.com/ponzischeme89/memby/server/internal/config" "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. 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)) 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")) } } 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) } } 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{}) for _, path := range []string{"/admin/", "/admin/api/status"} { 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) } } } 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) } } func TestAdminRuntimeMetricsAreProtectedAndReportHeap(t *testing.T) { server := testServer(config.Config{AdminToken: "secret"}) 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) rec := httptest.NewRecorder() server.adminRoutes().ServeHTTP(rec, req) 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) { 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{ "library", "recommendations", "requests", "ratings", "maintenance", "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) } } 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) } } 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", }) 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) if rec.Code != http.StatusUnauthorized { t.Fatalf("admin cookie without Emby gate got %d, want 401", rec.Code) } } 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) } }) }