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") } }