Publish current app and server
This commit is contained in:
@@ -1,13 +1,23 @@
|
||||
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) {
|
||||
@@ -43,15 +53,25 @@ func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
|
||||
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}}
|
||||
s := &Server{cfg: config.Config{
|
||||
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
|
||||
}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.54.apk", nil)
|
||||
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)
|
||||
@@ -61,3 +81,285 @@ func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
|
||||
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"},
|
||||
"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, "Memby test", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user