The user switcher fell back to "Administrator" whenever /admin/api/status returned no currentUser. Fresh sign-ins already embed the verified Emby account name in the session cookie, but a session minted before that field existed (or one that has not been re-issued since) is valid yet anonymous, so the fallback showed for ever. handleAdminConsole now treats a valid-but-anonymous admin session as needing a fresh sign-in for the SPA shell only — a one-time prompt that fills the name in, since session renewal preserves whatever the cookie already held. Asset requests are unaffected, so nothing breaks mid-session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wS9Qz3Fkxeu9KPt26hhhx
228 lines
7.7 KiB
Go
228 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
|
)
|
|
|
|
// embyAccessLevel stands in for an Emby whose answer about a person's access level is
|
|
// under the test's control. userPolicy is the policy the authentication response carries
|
|
// ("" for a server that sends none, which is what sends the gate to ask again), and
|
|
// lookupPolicy is what a direct read of the account then answers ("" to fail that read).
|
|
func embyAccessLevel(t *testing.T, userPolicy, lookupPolicy string) *Server {
|
|
t.Helper()
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/Users/AuthenticateByName":
|
|
policy := ""
|
|
if userPolicy != "" {
|
|
policy = `,"Policy":` + userPolicy
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{
|
|
"User":{"Id":"emby-user","Name":"Viewer"`+policy+`},
|
|
"AccessToken":"temporary-emby-token",
|
|
"ServerId":"emby-server"
|
|
}`)
|
|
case r.URL.Path == "/Users/emby-user":
|
|
if lookupPolicy == "" {
|
|
http.Error(w, "no", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w,
|
|
`{"Id":"emby-user","Name":"Viewer","Policy":`+lookupPolicy+`}`)
|
|
case r.URL.Path == "/Sessions/Logout":
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case r.URL.Path == "/Devices":
|
|
if r.Method == http.MethodGet {
|
|
_, _ = io.WriteString(w, `{"Items":[]}`)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
return &Server{
|
|
cfg: config.Config{
|
|
AdminToken: "secret",
|
|
ReleasePublishToken: "release-secret",
|
|
SyncUserID: "service-user", SyncAPIKey: "service-token",
|
|
},
|
|
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", 2*time.Second),
|
|
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
}
|
|
}
|
|
|
|
func signIn(s *Server, next string) *httptest.ResponseRecorder {
|
|
form := url.Values{
|
|
"username": {"Viewer"}, "password": {"correct horse"}, "next": {next},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/install/login",
|
|
strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
s.handleInstallLogin(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
|
for _, cookie := range rec.Result().Cookies() {
|
|
if cookie.Name == installerCookieName {
|
|
return cookie
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The console administers the server, so knowing a household password is not the
|
|
// qualification for reaching it. Emby's own user policy is the authority on who may, and
|
|
// it is the one an operator already manages.
|
|
func TestAdminSignInRequiresAnEmbyAdministrator(t *testing.T) {
|
|
s := embyAccessLevel(t, `{"IsAdministrator":false}`, "")
|
|
|
|
rec := signIn(s, "/admin/")
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("non-administrator sign-in = %d, want 403", rec.Code)
|
|
}
|
|
if sessionCookie(rec) != nil {
|
|
t.Fatal("a refused admin sign-in issued a session anyway")
|
|
}
|
|
// The wording must not separate "wrong password" from "right password, wrong person":
|
|
// somebody who may not administer the server has no business learning either.
|
|
if !strings.Contains(rec.Body.String(), "Sign-in failed.") {
|
|
t.Fatalf("refusal discloses more than a failed sign-in: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// The installer is the other half of the same gate and is public by design: a viewer
|
|
// putting Memby on a new television signs in there and must still be able to.
|
|
func TestNonAdministratorMayStillUseTheInstaller(t *testing.T) {
|
|
s := embyAccessLevel(t, `{"IsAdministrator":false}`, "")
|
|
|
|
rec := signIn(s, "/install")
|
|
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("installer sign-in = %d, want 303", rec.Code)
|
|
}
|
|
cookie := sessionCookie(rec)
|
|
if cookie == nil {
|
|
t.Fatal("installer sign-in issued no session")
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/install", nil)
|
|
req.AddCookie(cookie)
|
|
if !s.validInstallerSession(req) {
|
|
t.Fatal("a viewer's installer session was not accepted by the installer")
|
|
}
|
|
if s.validAdminSession(req) {
|
|
t.Fatal("a viewer's installer session was accepted as an admin session")
|
|
}
|
|
}
|
|
|
|
func TestAdministratorSignInMintsAnAdminSession(t *testing.T) {
|
|
s := embyAccessLevel(t, `{"IsAdministrator":true}`, "")
|
|
|
|
rec := signIn(s, "/admin/overview")
|
|
|
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/overview" {
|
|
t.Fatalf("admin sign-in = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
cookie := sessionCookie(rec)
|
|
if cookie == nil {
|
|
t.Fatal("admin sign-in issued no session")
|
|
}
|
|
if cookie.MaxAge != int(adminSessionTTL/time.Second) {
|
|
t.Fatalf("admin cookie MaxAge = %d, want %d",
|
|
cookie.MaxAge, int(adminSessionTTL/time.Second))
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
|
req.AddCookie(cookie)
|
|
if !s.validAdminSession(req) {
|
|
t.Fatal("an administrator's session was not accepted by the console")
|
|
}
|
|
_, username, ok := s.browserSession(req, adminSessionPurpose)
|
|
if !ok || username != "Viewer" {
|
|
t.Fatalf("admin session identity = %q, valid %t; want Viewer", username, ok)
|
|
}
|
|
}
|
|
|
|
// An Emby that returns no policy at all is a question, not a refusal — reading its silence
|
|
// as "not an administrator" would lock an operator out of their own console.
|
|
func TestAdminSignInAsksEmbyWhenTheAuthResponseCarriesNoPolicy(t *testing.T) {
|
|
s := embyAccessLevel(t, "", `{"IsAdministrator":true}`)
|
|
|
|
rec := signIn(s, "/admin/")
|
|
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("sign-in = %d, want the policy to be read directly and accepted", rec.Code)
|
|
}
|
|
cookie := sessionCookie(rec)
|
|
if cookie == nil {
|
|
t.Fatal("no session issued after the access level was read directly")
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
|
req.AddCookie(cookie)
|
|
if !s.validAdminSession(req) {
|
|
t.Fatal("the session issued was not an admin session")
|
|
}
|
|
}
|
|
|
|
// The user switcher names whoever is signed in, so an admin session must carry the
|
|
// verified Emby account name. A fresh sign-in does; a session predating the identity in
|
|
// the cookie is still valid but anonymous, and the console handler prompts those to sign
|
|
// in again rather than falling back to "Administrator" for ever.
|
|
func TestAdminSessionCarriesTheVerifiedName(t *testing.T) {
|
|
s := embyAccessLevel(t, `{"IsAdministrator":true}`, "")
|
|
|
|
cookie := sessionCookie(signIn(s, "/admin/"))
|
|
if cookie == nil {
|
|
t.Fatal("admin sign-in issued no session")
|
|
}
|
|
named := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
|
named.AddCookie(cookie)
|
|
if !s.adminSessionNamed(named) {
|
|
t.Fatal("a fresh admin session is missing the verified name")
|
|
}
|
|
|
|
anon, err := s.newBrowserSessionFor(adminSessionPurpose, adminSessionTTL, "")
|
|
if err != nil {
|
|
t.Fatalf("anonymous session: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
|
req.AddCookie(&http.Cookie{Name: installerCookieName, Value: anon})
|
|
if !s.validAdminSession(req) {
|
|
t.Fatal("an anonymous admin session should still be valid")
|
|
}
|
|
if s.adminSessionNamed(req) {
|
|
t.Fatal("an anonymous admin session should not report a name")
|
|
}
|
|
}
|
|
|
|
// And an Emby that will not answer either way must not be guessed at in the permissive
|
|
// direction: no answer means no session.
|
|
func TestAdminSignInIsRefusedWhenTheAccessLevelCannotBeRead(t *testing.T) {
|
|
s := embyAccessLevel(t, "", "")
|
|
|
|
rec := signIn(s, "/admin/")
|
|
|
|
if rec.Code != http.StatusBadGateway {
|
|
t.Fatalf("unreadable access level = %d, want 502", rec.Code)
|
|
}
|
|
if sessionCookie(rec) != nil {
|
|
t.Fatal("a session was issued without knowing the access level")
|
|
}
|
|
}
|