367 lines
12 KiB
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)
|
|
}
|
|
}
|
|
}
|