Files
memby/server/internal/api/release_test.go

367 lines
12 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
)
func TestReleasePublishAuth(t *testing.T) {
handler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
t.Run("disabled is hidden", func(t *testing.T) {
s := &Server{cfg: config.Config{}}
rec := httptest.NewRecorder()
s.releasePublishAuth(handler).ServeHTTP(
rec,
httptest.NewRequest(http.MethodPost, "/admin/api/release", nil),
)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
})
t.Run("valid bearer token is accepted", func(t *testing.T) {
s := &Server{cfg: config.Config{ReleasePublishToken: "release-secret"}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/release", nil)
req.Header.Set("Authorization", "Bearer release-secret")
s.releasePublishAuth(handler).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("got %d, want 204", rec.Code)
}
})
}
func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
dir := t.TempDir()
payload := []byte("PK signed apk")
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
s.signedReleasePath("memby-0.1.54.apk"), nil)
req.SetPathValue("filename", "memby-0.1.54.apk")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(payload) {
t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String())
}
if disposition := rec.Header().Get("Content-Disposition"); disposition !=
`attachment; filename="memby-0.1.54.apk"` {
t.Fatalf("Content-Disposition = %q", disposition)
}
if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") {
t.Fatalf("APK crawler policy = %q", rec.Header().Get("X-Robots-Tag"))
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil)
req.SetPathValue("filename", "../secrets")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("invalid filename got %d, want 404", rec.Code)
}
}
func TestInstallPageAndLatestDownloadUsePublishedRelease(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
}}
s.updatePolicy.set(appupdate.Policy{
LatestVersion: "0.1.72",
Notes: `<script>alert("no")</script>`,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/install", nil)
addInstallerSession(t, s, req)
s.handleInstallPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("install page status = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Download Memby 0.1.72") ||
!strings.Contains(body, `href="/updates/latest.apk"`) {
t.Fatalf("install page is missing current release details: %s", body)
}
if strings.Contains(body, `<script>alert`) {
t.Fatal("release notes were rendered without HTML escaping")
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("install page cache policy = %q", rec.Header().Get("Cache-Control"))
}
if robots := rec.Header().Get("X-Robots-Tag"); !strings.Contains(robots, "noindex") ||
!strings.Contains(robots, "nofollow") {
t.Fatalf("install page crawler policy = %q", robots)
}
if !strings.Contains(body, `name="robots" content="noindex,nofollow`) {
t.Fatal("install page has no crawler metadata")
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/latest.apk", nil)
addInstallerSession(t, s, req)
s.handleLatestReleaseDownload(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("latest APK status = %d", rec.Code)
}
if location := rec.Header().Get("Location"); location != "" {
t.Fatalf("latest APK unexpectedly redirected to %q", location)
}
if contentType := rec.Header().Get("Content-Type"); contentType != "application/vnd.android.package-archive" {
t.Fatalf("latest APK content type = %q", contentType)
}
if disposition := rec.Header().Get("Content-Disposition"); disposition != `attachment; filename="memby-0.1.72.apk"` {
t.Fatalf("latest APK disposition = %q", disposition)
}
if rec.Body.String() != "apk" {
t.Fatalf("latest APK body = %q", rec.Body.String())
}
}
func TestRobotsDisallowsEntireHost(t *testing.T) {
rec := httptest.NewRecorder()
handleRobots(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if rec.Code != http.StatusOK {
t.Fatalf("robots status = %d", rec.Code)
}
if got := rec.Body.String(); got != "User-agent: *\nDisallow: /\n" {
t.Fatalf("robots body = %q", got)
}
if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") {
t.Fatalf("robots response crawler policy = %q", rec.Header().Get("X-Robots-Tag"))
}
}
func TestInstallPageIsUnavailableWithoutPublishedAPK(t *testing.T) {
s := &Server{cfg: config.Config{
ReleaseDir: t.TempDir(), ReleasePublishToken: "test-release-secret",
}}
s.updatePolicy.set(appupdate.Policy{LatestVersion: "0.1.72"})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/install", nil)
addInstallerSession(t, s, req)
s.handleInstallPage(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("install page status = %d, want 503", rec.Code)
}
if !strings.Contains(rec.Body.String(), "installer is not available yet") {
t.Fatalf("unavailable page gave no useful message: %s", rec.Body.String())
}
}
func TestInstallerHidesReleaseUntilEmbyAuthenticated(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
}}
s.updatePolicy.set(appupdate.Policy{LatestVersion: "0.1.72"})
rec := httptest.NewRecorder()
s.handleInstallPage(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
if rec.Code != http.StatusOK {
t.Fatalf("login page status = %d", rec.Code)
}
body := rec.Body.String()
lowerBody := strings.ToLower(body)
if !strings.Contains(body, "Username") || !strings.Contains(body, "Password") ||
strings.Contains(lowerBody, "memby") || strings.Contains(lowerBody, "emby") ||
strings.Contains(lowerBody, "installer") || strings.Contains(body, "0.1.72") {
t.Fatalf("unauthenticated page leaked release access: %s", body)
}
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/updates/latest.apk", nil)
s.handleLatestReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("unauthenticated latest APK status = %d, want 404", rec.Code)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.72.apk", nil)
req.SetPathValue("filename", "memby-0.1.72.apk")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("unsigned APK URL status = %d, want 404", rec.Code)
}
}
func TestInstallerIsHiddenWhenReleaseSecretIsUnconfigured(t *testing.T) {
s := &Server{cfg: config.Config{ReleaseDir: t.TempDir()}}
rec := httptest.NewRecorder()
s.handleInstallPage(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("unconfigured installer status = %d, want 404", rec.Code)
}
}
func TestInstallerLoginDoesNotAuthenticateWithoutDeviceCleanupCredential(t *testing.T) {
s := &Server{
cfg: config.Config{ReleasePublishToken: "test-release-secret"},
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
form := url.Values{"username": {"Matt"}, "password": {"password"}}
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)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("installer login status = %d, want 503", rec.Code)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("installer login issued a session without a device cleanup credential")
}
}
func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) {
var logoutCalls atomic.Int32
var deleteCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Users/AuthenticateByName":
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"User":{"Id":"emby-user","Name":"Matt",
"Policy":{"IsAdministrator":true}},
"AccessToken":"temporary-emby-token",
"ServerId":"emby-server"
}`)
case "/Sessions/Logout":
logoutCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
case "/Devices":
if r.Method == http.MethodGet {
_, _ = io.WriteString(w, `{"Items":[
{"Id":"keep","ReportedDeviceId":"living-room-tv"},
{"Id":"remove","ReportedDeviceId":"memby-web-installer"}
]}`)
} else if r.Method == http.MethodDelete && r.URL.Query().Get("Id") == "remove" {
deleteCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
} else {
http.Error(w, "unexpected device request", http.StatusBadRequest)
}
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
s := &Server{
cfg: config.Config{
ReleasePublishToken: "test-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)),
}
form := url.Values{
"username": {"Matt"}, "password": {"correct horse"}, "next": {"/admin/"},
}
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)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("login response = %d, location %q", rec.Code, rec.Header().Get("Location"))
}
if logoutCalls.Load() != 1 {
t.Fatalf("Emby logout calls = %d, want 1", logoutCalls.Load())
}
if deleteCalls.Load() != 1 {
t.Fatalf("Emby device delete calls = %d, want 1", deleteCalls.Load())
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != installerCookieName ||
!cookies[0].HttpOnly || !cookies[0].Secure ||
cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("installer cookie is not hardened: %+v", cookies)
}
follow := httptest.NewRequest(http.MethodGet, "/install", nil)
follow.AddCookie(cookies[0])
if !s.validInstallerSession(follow) {
t.Fatal("issued installer session was not accepted")
}
}
func addInstallerSession(t *testing.T, s *Server, req *http.Request) {
t.Helper()
value, err := s.newInstallerSession()
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: installerCookieName, Value: value})
}
func TestFileSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "release.apk")
payload := []byte("a complete release payload")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(payload)
want := hex.EncodeToString(sum[:])
got, err := fileSHA256(path)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("fileSHA256() = %q, want %q", got, want)
}
}
func TestParseMandatoryRelease(t *testing.T) {
for _, test := range []struct {
value string
mandatory bool
valid bool
}{
{"", false, true},
{"0", false, true},
{"false", false, true},
{"1", true, true},
{"TRUE", true, true},
{"sometimes", false, false},
} {
mandatory, valid := parseMandatoryRelease(test.value)
if mandatory != test.mandatory || valid != test.valid {
t.Errorf("parseMandatoryRelease(%q) = (%v, %v), want (%v, %v)",
test.value, mandatory, valid, test.mandatory, test.valid)
}
}
}