2026-07-27 08:16:20 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-06 22:33:56 +12:00
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/binary"
|
2026-07-27 08:16:20 +12:00
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-08-02 22:10:19 +12:00
|
|
|
"slices"
|
|
|
|
|
"strings"
|
2026-07-27 08:16:20 +12:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
2026-08-02 22:10:19 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
2026-07-27 08:16:20 +12:00
|
|
|
"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",
|
|
|
|
|
})
|
2026-07-27 08:16:20 +12:00
|
|
|
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-06 22:33:56 +12:00
|
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/overview" {
|
|
|
|
|
t.Fatalf("admin root should stay reachable via overview redirect, got %d %q",
|
2026-08-02 22:10:19 +12:00
|
|
|
rec.Code, rec.Header().Get("Location"))
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// installerSessionExpiring mints a session with a chosen life left, which is the only way
|
2026-08-10 08:37:08 +12:00
|
|
|
// to reach the renewal window without waiting six hours in a test.
|
2026-08-06 22:33:56 +12:00
|
|
|
func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
|
|
|
|
|
t.Helper()
|
|
|
|
|
payload := make([]byte, 8+16)
|
|
|
|
|
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(remaining).Unix()))
|
|
|
|
|
signature := s.signInstallerValue("session", payload)
|
|
|
|
|
return &http.Cookie{
|
|
|
|
|
Name: installerCookieName,
|
|
|
|
|
Value: base64.RawURLEncoding.EncodeToString(payload) + "." +
|
|
|
|
|
base64.RawURLEncoding.EncodeToString(signature),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func adminRequest(s *Server, remaining time.Duration, t *testing.T) *http.Request {
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
|
|
|
|
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: s.cfg.AdminToken})
|
|
|
|
|
req.AddCookie(installerSessionExpiring(t, s, remaining))
|
|
|
|
|
return req
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renewedCookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
|
|
|
|
for _, cookie := range rec.Result().Cookies() {
|
|
|
|
|
if cookie.Name == installerCookieName {
|
|
|
|
|
return cookie
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The admin sign-in used to be an absolute half hour: an operator was signed out from
|
|
|
|
|
// under themselves mid-edit, and the console's poll then reported "invalid admin token"
|
2026-08-10 08:37:08 +12:00
|
|
|
// with no way back to a login. A renewed admin session now lasts a full working day.
|
2026-08-06 22:33:56 +12:00
|
|
|
func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(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 := adminRequest(server, 2*time.Minute, t)
|
|
|
|
|
req.Header.Set(adminActivityHeader, "1")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("a session with two minutes left must still be accepted, got %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
cookie := renewedCookie(rec)
|
|
|
|
|
if cookie == nil {
|
|
|
|
|
t.Fatal("expected a refreshed installer cookie")
|
|
|
|
|
}
|
2026-08-10 08:37:08 +12:00
|
|
|
if cookie.MaxAge != int(adminSessionTTL/time.Second) {
|
|
|
|
|
t.Fatalf("renewed admin cookie MaxAge = %d, want %d",
|
|
|
|
|
cookie.MaxAge, int(adminSessionTTL/time.Second))
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
|
|
|
|
follow.AddCookie(cookie)
|
|
|
|
|
expires, ok := server.installerSessionExpiry(follow)
|
2026-08-10 08:37:08 +12:00
|
|
|
if !ok || time.Until(expires) < adminSessionTTL-time.Minute {
|
2026-08-06 22:33:56 +12:00
|
|
|
t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)",
|
|
|
|
|
time.Until(expires), ok)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The other half of the rule: a tab left open on a second monitor polls by itself, so a
|
|
|
|
|
// session that renewed on any request at all would never expire.
|
|
|
|
|
func TestAdminSessionIsNotExtendedByThePollAlone(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)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, adminRequest(server, 2*time.Minute, t))
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("the poll itself should still be served, got %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if cookie := renewedCookie(rec); cookie != nil {
|
|
|
|
|
t.Fatal("an unattended poll must not extend the sign-in")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Renewal rewrites a cookie, so it waits until there is something to gain. A working
|
|
|
|
|
// console makes a request every few seconds and must not re-issue on each one.
|
|
|
|
|
func TestAdminSessionIsNotRewrittenWhileItIsStillFresh(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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
req := adminRequest(server, adminSessionTTL-time.Minute, t)
|
2026-08-06 22:33:56 +12:00
|
|
|
req.Header.Set(adminActivityHeader, "1")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if cookie := renewedCookie(rec); cookie != nil {
|
|
|
|
|
t.Fatal("a fresh session should not be re-issued")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A mutation is an operator by definition — nothing else sends one — so it needs no
|
|
|
|
|
// header to be believed.
|
|
|
|
|
func TestAdminMutationExtendsTheSessionWithoutTheActivityHeader(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.MethodPost, "/admin/api/sync", nil)
|
|
|
|
|
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: server.cfg.AdminToken})
|
|
|
|
|
req.AddCookie(installerSessionExpiring(t, server, 2*time.Minute))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if renewedCookie(rec) == nil {
|
|
|
|
|
t.Fatal("an operator action must extend the sign-in")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Automation presents the token as a Bearer header and holds no session at all; renewal
|
|
|
|
|
// must not go looking for one.
|
|
|
|
|
func TestBearerAutomationIsUnaffectedByRenewal(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.MethodPost, "/admin/api/deployment-alert", nil)
|
|
|
|
|
req.Header.Set("Authorization", "Bearer secret")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("bearer automation should be served, got %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if renewedCookie(rec) != nil {
|
|
|
|
|
t.Fatal("a tokened request should not be handed a session cookie")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-08-06 22:33:56 +12:00
|
|
|
// The status poll is the app's only continuous channel, so everything that has to reach a
|
|
|
|
|
// television between requests rides on it. These two are the newest passengers, and both
|
|
|
|
|
// fail silently if a field is renamed: the outage bar simply never appears, and an
|
|
|
|
|
// operator's settings push is never collected.
|
|
|
|
|
func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
|
|
|
|
server := testServer(config.Config{})
|
|
|
|
|
server.embyHealth.begin(60*time.Second, time.Now().UTC())
|
|
|
|
|
server.embyHealth.record(false, time.Now().UTC())
|
|
|
|
|
server.embyHealth.record(false, time.Now().UTC())
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
server.handleServiceStatus(
|
|
|
|
|
rec, httptest.NewRequest(http.MethodGet, "/v1/status", nil), store.Session{})
|
|
|
|
|
|
|
|
|
|
var body map[string]any
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
health, ok := body["emby"].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("status response carried no emby health: %v", body)
|
|
|
|
|
}
|
|
|
|
|
if health["monitored"] != true || health["reachable"] != false {
|
|
|
|
|
t.Fatalf("emby health did not report the outage: %v", health)
|
|
|
|
|
}
|
|
|
|
|
if health["retrySeconds"] != float64(60) {
|
|
|
|
|
t.Fatalf("retrySeconds = %v, want 60 — the bar counts down from this", health["retrySeconds"])
|
|
|
|
|
}
|
|
|
|
|
// Present even with no store behind it: a client comparing against a missing field
|
|
|
|
|
// would never notice a push.
|
|
|
|
|
if _, ok := body["preferencesRevision"]; !ok {
|
|
|
|
|
t.Fatalf("status response carried no preferences revision: %v", body)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// With the probe switched off there is nothing to say, and a client must not be handed a
|
|
|
|
|
// value it could read as an outage.
|
|
|
|
|
func TestServiceStatusReportsEmbyHealthyWhenUnmonitored(t *testing.T) {
|
|
|
|
|
server := testServer(config.Config{})
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
server.handleServiceStatus(
|
|
|
|
|
rec, httptest.NewRequest(http.MethodGet, "/v1/status", nil), store.Session{})
|
|
|
|
|
|
|
|
|
|
var body map[string]any
|
|
|
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
|
|
|
health := body["emby"].(map[string]any)
|
|
|
|
|
if health["monitored"] != false || health["reachable"] != true {
|
|
|
|
|
t.Fatalf("an unmonitored server was not reported healthy: %v", health)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(ProtocolVersion) {
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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"} {
|
2026-07-27 08:16:20 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
var cookie *http.Cookie
|
|
|
|
|
for _, candidate := range rec.Result().Cookies() {
|
|
|
|
|
if candidate.Name == adminCookieName {
|
|
|
|
|
cookie = candidate
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if cookie == nil {
|
|
|
|
|
t.Fatal("admin page did not establish its persistent cookie")
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
func TestAdminPageOffersLogout(t *testing.T) {
|
|
|
|
|
server := testServer(config.Config{
|
|
|
|
|
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
|
|
|
|
})
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/overview", nil)
|
|
|
|
|
addInstallerSession(t, server, req)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
server.adminRoutes().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if !strings.Contains(rec.Body.String(), `method="post" action="/admin/logout"`) ||
|
|
|
|
|
!strings.Contains(rec.Body.String(), ">Log out</span>") {
|
|
|
|
|
t.Fatal("admin shell does not offer logout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAdminLogoutClearsBothBrowserCookies(t *testing.T) {
|
|
|
|
|
server := testServer(config.Config{
|
|
|
|
|
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
|
|
|
|
})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "https://memby.local/admin/logout", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
server.adminRoutes().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
|
|
|
|
t.Fatalf("logout = %d %q, want 303 to admin gate", rec.Code, rec.Header().Get("Location"))
|
|
|
|
|
}
|
|
|
|
|
cleared := map[string]*http.Cookie{}
|
|
|
|
|
for _, cookie := range rec.Result().Cookies() {
|
|
|
|
|
cleared[cookie.Name] = cookie
|
|
|
|
|
}
|
|
|
|
|
for name, path := range map[string]string{
|
|
|
|
|
installerCookieName: "/", adminCookieName: "/admin",
|
|
|
|
|
} {
|
|
|
|
|
cookie := cleared[name]
|
|
|
|
|
if cookie == nil || cookie.MaxAge >= 0 || cookie.Path != path || !cookie.HttpOnly || !cookie.Secure {
|
|
|
|
|
t.Fatalf("logout did not clear %s safely: %#v", name, cookie)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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-06 22:33:56 +12:00
|
|
|
"overview", "accounts", "clients", "library", "recommendations", "inspector",
|
|
|
|
|
"requests", "ratings", "features", "playback", "maintenance", "updates",
|
|
|
|
|
"engagement", "imports", "logs",
|
2026-08-02 22:10:19 +12:00
|
|
|
} {
|
|
|
|
|
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)
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
body := rec.Body.String()
|
|
|
|
|
if !strings.Contains(body, "This is the Memby user list, not the Emby user directory") {
|
|
|
|
|
t.Fatal("accounts page does not distinguish Memby users from Emby accounts")
|
|
|
|
|
}
|
|
|
|
|
// The directory lists people and links onwards. Everything an operator can *do* to
|
|
|
|
|
// somebody lives on that person's own page, so it must not be here.
|
|
|
|
|
for _, unwanted := range []string{"Remove Memby access", "Push to their televisions"} {
|
|
|
|
|
if strings.Contains(body, unwanted) {
|
|
|
|
|
t.Fatalf("accounts directory still carries the per-account editor: %q", unwanted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAccountPageCarriesTheManagementForOnePerson(t *testing.T) {
|
|
|
|
|
server := testServer(config.Config{
|
|
|
|
|
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
|
|
|
|
})
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/accounts/user-7", nil)
|
|
|
|
|
addInstallerSession(t, server, req)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
server.adminRoutes().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("/admin/accounts/user-7 = %d", rec.Code)
|
|
|
|
|
}
|
2026-08-03 10:16:44 +12:00
|
|
|
body := rec.Body.String()
|
|
|
|
|
for _, wanted := range []string{
|
2026-08-06 22:33:56 +12:00
|
|
|
`data-admin-page="account"`, `id="account-devices"`, "Recommendation setup",
|
|
|
|
|
"Remove Memby access", "Clear stored choices", "Push to their televisions",
|
|
|
|
|
`href="/admin/accounts"`,
|
2026-08-03 10:16:44 +12:00
|
|
|
} {
|
|
|
|
|
if !strings.Contains(body, wanted) {
|
2026-08-06 22:33:56 +12:00
|
|
|
t.Fatalf("account page does not contain %q", wanted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hidden pages are addressed by the route that carries the identity, never by name.
|
|
|
|
|
unnamed := httptest.NewRequest(http.MethodGet, "/admin/account", nil)
|
|
|
|
|
addInstallerSession(t, server, unnamed)
|
|
|
|
|
rec = httptest.NewRecorder()
|
|
|
|
|
server.adminRoutes().ServeHTTP(rec, unnamed)
|
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("/admin/account = %d, want 404", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if cleanInstallerDestination("/admin/account") != "/install" {
|
|
|
|
|
t.Fatal("a sign-in may not return to an account page with nobody to be about")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The console was one file holding every screen at once, all but one of them hidden. A page
|
|
|
|
|
// must now carry its own markup and nothing else: this is what stops that regressing.
|
|
|
|
|
func TestAdminPagesShipOnlyTheirOwnMarkup(t *testing.T) {
|
|
|
|
|
server := testServer(config.Config{
|
|
|
|
|
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
|
|
|
|
})
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
|
|
|
|
|
addInstallerSession(t, server, req)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
server.adminRoutes().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
body := rec.Body.String()
|
|
|
|
|
if !strings.Contains(body, `id="log"`) {
|
|
|
|
|
t.Fatal("logs page does not contain the log viewer")
|
|
|
|
|
}
|
|
|
|
|
for _, foreign := range []string{
|
|
|
|
|
`id="mdblist-sources"`, `id="feature-list"`, `id="account-settings"`,
|
|
|
|
|
`id="inspector-results"`, `id="update-version"`,
|
|
|
|
|
} {
|
|
|
|
|
if strings.Contains(body, foreign) {
|
|
|
|
|
t.Fatalf("logs page still ships another page's markup: %q", foreign)
|
2026-08-03 10:16:44 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|