App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -97,12 +99,159 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
|
||||
|
||||
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",
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/overview" {
|
||||
t.Fatalf("admin root should stay reachable via overview redirect, got %d %q",
|
||||
rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// installerSessionExpiring mints a session with a chosen life left, which is the only way
|
||||
// to reach the renewal window without waiting a quarter of an hour in a test.
|
||||
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"
|
||||
// with no way back to a login.
|
||||
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")
|
||||
}
|
||||
follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
||||
follow.AddCookie(cookie)
|
||||
expires, ok := server.installerSessionExpiry(follow)
|
||||
if !ok || time.Until(expires) < installerSessionTTL-time.Minute {
|
||||
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)
|
||||
})
|
||||
|
||||
req := adminRequest(server, installerSessionTTL-time.Minute, t)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
|
||||
@@ -125,6 +274,58 @@ func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||
@@ -141,7 +342,7 @@ func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
|
||||
if body["compatible"] != false || body["compatibilityMessage"] == "" {
|
||||
t.Fatalf("mismatch was not explicit: %v", body)
|
||||
}
|
||||
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(membyProtocolVersion) {
|
||||
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(ProtocolVersion) {
|
||||
t.Fatalf("version diagnostics missing: %v", body)
|
||||
}
|
||||
}
|
||||
@@ -323,8 +524,9 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
|
||||
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
||||
})
|
||||
for _, page := range []string{
|
||||
"accounts", "library", "recommendations", "requests", "ratings", "maintenance",
|
||||
"updates", "engagement", "imports", "logs",
|
||||
"overview", "accounts", "clients", "library", "recommendations", "inspector",
|
||||
"requests", "ratings", "features", "playback", "maintenance", "updates",
|
||||
"engagement", "imports", "logs",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
|
||||
addInstallerSession(t, server, req)
|
||||
@@ -359,14 +561,78 @@ func TestAccountsPageDistinguishesMembyFromEmbyAndProvidesManagement(t *testing.
|
||||
|
||||
server.adminRoutes().ServeHTTP(rec, req)
|
||||
|
||||
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)
|
||||
}
|
||||
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",
|
||||
`data-admin-page="account"`, `id="account-devices"`, "Recommendation setup",
|
||||
"Remove Memby access", "Clear stored choices", "Push to their televisions",
|
||||
`href="/admin/accounts"`,
|
||||
} {
|
||||
if !strings.Contains(body, wanted) {
|
||||
t.Fatalf("accounts page does not contain %q", wanted)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user