mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-28 10:31:27 +02:00
dbba4d6630
- Bind cancel context to all extension HTTP calls (fetch, httpGet, httpPost, httpRequest, fileDownload, authExchangeCodeWithPKCE) so in-flight requests are aborted when user cancels a download - Make initDownloadCancel idempotent: return existing context if entry already exists and preserve pre-cancelled state - Force SAF output filename to match actual file extension when extension returns a different format than requested (e.g. FLAC requested but M4A produced) - Map ALAC/AAC quality to .m4a instead of falling through to default .flac
436 lines
10 KiB
Go
436 lines
10 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"net/http"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
func TestParseManifest_Valid(t *testing.T) {
|
|
validManifest := `{
|
|
"name": "test-provider",
|
|
"displayName": "Test Provider",
|
|
"version": "1.0.0",
|
|
"description": "A test extension",
|
|
"type": ["metadata_provider"],
|
|
"permissions": {
|
|
"network": ["api.test.com"],
|
|
"storage": true
|
|
}
|
|
}`
|
|
|
|
manifest, err := ParseManifest([]byte(validManifest))
|
|
if err != nil {
|
|
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
|
|
}
|
|
|
|
if manifest.Name != "test-provider" {
|
|
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
|
|
}
|
|
|
|
if manifest.Version != "1.0.0" {
|
|
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
|
|
}
|
|
|
|
if !manifest.IsMetadataProvider() {
|
|
t.Error("Expected IsMetadataProvider() to return true")
|
|
}
|
|
|
|
if manifest.IsDownloadProvider() {
|
|
t.Error("Expected IsDownloadProvider() to return false")
|
|
}
|
|
}
|
|
|
|
func TestParseManifest_MissingName(t *testing.T) {
|
|
invalidManifest := `{
|
|
"version": "1.0.0",
|
|
"description": "A test extension",
|
|
"type": ["metadata_provider"]
|
|
}`
|
|
|
|
_, err := ParseManifest([]byte(invalidManifest))
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing name")
|
|
}
|
|
}
|
|
|
|
func TestParseManifest_MissingType(t *testing.T) {
|
|
invalidManifest := `{
|
|
"name": "test-provider",
|
|
"version": "1.0.0",
|
|
"description": "A test extension"
|
|
}`
|
|
|
|
_, err := ParseManifest([]byte(invalidManifest))
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing type")
|
|
}
|
|
}
|
|
|
|
func TestIsDomainAllowed(t *testing.T) {
|
|
manifest := &ExtensionManifest{
|
|
Permissions: ExtensionPermissions{
|
|
Network: []string{"api.test.com", "*.example.com"},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
domain string
|
|
expected bool
|
|
}{
|
|
{"api.test.com", true},
|
|
{"api.example.com", true},
|
|
{"sub.example.com", true},
|
|
{"notallowed.com", false},
|
|
{"test.com", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := manifest.IsDomainAllowed(tt.domain)
|
|
if result != tt.expected {
|
|
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|
// Create a mock extension with limited network permissions
|
|
ext := &loadedExtension{
|
|
ID: "test-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext",
|
|
Permissions: ExtensionPermissions{
|
|
Network: []string{"api.allowed.com", "*.wildcard.com"},
|
|
},
|
|
},
|
|
DataDir: t.TempDir(),
|
|
}
|
|
|
|
runtime := newExtensionRuntime(ext)
|
|
|
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
|
}
|
|
|
|
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
|
|
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
|
}
|
|
|
|
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
|
t.Error("Expected blocked.com to be denied")
|
|
}
|
|
|
|
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
|
t.Error("Expected notallowed.com to be denied")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
ext := &loadedExtension{
|
|
ID: "test-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext",
|
|
Permissions: ExtensionPermissions{
|
|
File: true,
|
|
},
|
|
},
|
|
DataDir: tempDir,
|
|
}
|
|
|
|
runtime := newExtensionRuntime(ext)
|
|
|
|
validPath, err := runtime.validatePath("test.txt")
|
|
if err != nil {
|
|
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
|
}
|
|
if validPath == "" {
|
|
t.Error("Expected non-empty path")
|
|
}
|
|
|
|
_, err = runtime.validatePath("../../../etc/passwd")
|
|
if err == nil {
|
|
t.Error("Expected path traversal to be blocked")
|
|
}
|
|
|
|
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
|
if err != nil {
|
|
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
|
}
|
|
if nestedPath == "" {
|
|
t.Error("Expected non-empty nested path")
|
|
}
|
|
|
|
var absPath string
|
|
if filepath.IsAbs("C:\\Windows\\System32") {
|
|
absPath = "C:\\Windows\\System32\\test.txt"
|
|
} else {
|
|
absPath = "/etc/passwd"
|
|
}
|
|
_, err = runtime.validatePath(absPath)
|
|
if err == nil {
|
|
t.Error("Expected absolute path to be blocked")
|
|
}
|
|
|
|
extNoFile := &loadedExtension{
|
|
ID: "test-ext-no-file",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext-no-file",
|
|
Permissions: ExtensionPermissions{
|
|
File: false,
|
|
},
|
|
},
|
|
DataDir: tempDir,
|
|
}
|
|
runtimeNoFile := newExtensionRuntime(extNoFile)
|
|
_, err = runtimeNoFile.validatePath("test.txt")
|
|
if err == nil {
|
|
t.Error("Expected file access to be denied without file permission")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|
ext := &loadedExtension{
|
|
ID: "test-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext",
|
|
},
|
|
DataDir: t.TempDir(),
|
|
}
|
|
|
|
runtime := newExtensionRuntime(ext)
|
|
vm := goja.New()
|
|
runtime.RegisterAPIs(vm)
|
|
|
|
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
|
if err != nil {
|
|
t.Fatalf("base64Encode failed: %v", err)
|
|
}
|
|
if result.String() != "aGVsbG8=" {
|
|
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
|
|
}
|
|
|
|
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
|
|
if err != nil {
|
|
t.Fatalf("base64Decode failed: %v", err)
|
|
}
|
|
if result.String() != "hello" {
|
|
t.Errorf("Expected 'hello', got '%s'", result.String())
|
|
}
|
|
|
|
result, err = vm.RunString(`utils.md5("hello")`)
|
|
if err != nil {
|
|
t.Fatalf("md5 failed: %v", err)
|
|
}
|
|
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
|
|
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
|
}
|
|
|
|
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
|
if err != nil {
|
|
t.Fatalf("stringifyJSON failed: %v", err)
|
|
}
|
|
// JSON output may vary in order, just check it's valid
|
|
if result.String() == "" {
|
|
t.Error("Expected non-empty JSON string")
|
|
}
|
|
|
|
result, err = vm.RunString(`utils.sleep(1)`)
|
|
if err != nil {
|
|
t.Fatalf("sleep failed: %v", err)
|
|
}
|
|
if !result.ToBoolean() {
|
|
t.Error("Expected sleep to complete successfully")
|
|
}
|
|
|
|
runtime.setActiveDownloadItemID("test-item")
|
|
cancelDownload("test-item")
|
|
t.Cleanup(func() {
|
|
clearDownloadCancel("test-item")
|
|
runtime.clearActiveDownloadItemID()
|
|
})
|
|
|
|
result, err = vm.RunString(`utils.isDownloadCancelled()`)
|
|
if err != nil {
|
|
t.Fatalf("isDownloadCancelled failed: %v", err)
|
|
}
|
|
if !result.ToBoolean() {
|
|
t.Error("Expected active download cancellation to be visible to JS")
|
|
}
|
|
|
|
SetAppVersion("4.2.2")
|
|
t.Cleanup(func() {
|
|
SetAppVersion("")
|
|
})
|
|
|
|
result, err = vm.RunString(`utils.appVersion()`)
|
|
if err != nil {
|
|
t.Fatalf("appVersion failed: %v", err)
|
|
}
|
|
if got := result.String(); got != "4.2.2" {
|
|
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
|
|
}
|
|
|
|
result, err = vm.RunString(`utils.appUserAgent()`)
|
|
if err != nil {
|
|
t.Fatalf("appUserAgent failed: %v", err)
|
|
}
|
|
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
|
|
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
|
|
}
|
|
|
|
result, err = vm.RunString(`utils.sleep(50)`)
|
|
if err != nil {
|
|
t.Fatalf("cancel-aware sleep failed: %v", err)
|
|
}
|
|
if result.ToBoolean() {
|
|
t.Error("Expected sleep to abort when download is cancelled")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
|
ext := &loadedExtension{
|
|
ID: "test-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext",
|
|
},
|
|
DataDir: t.TempDir(),
|
|
}
|
|
|
|
runtime := newExtensionRuntime(ext)
|
|
runtime.setActiveDownloadItemID("test-item")
|
|
t.Cleanup(func() {
|
|
clearDownloadCancel("test-item")
|
|
runtime.clearActiveDownloadItemID()
|
|
})
|
|
|
|
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
req = runtime.bindDownloadCancelContext(req)
|
|
cancelDownload("test-item")
|
|
|
|
select {
|
|
case <-req.Context().Done():
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("Expected bound request context to be cancelled")
|
|
}
|
|
|
|
if req.Context().Err() == nil {
|
|
t.Fatal("Expected request context error after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
|
|
ext := &loadedExtension{
|
|
ID: "test-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext",
|
|
},
|
|
DataDir: t.TempDir(),
|
|
}
|
|
|
|
runtime := newExtensionRuntime(ext)
|
|
runtime.setActiveDownloadItemID("test-item")
|
|
cancelDownload("test-item")
|
|
t.Cleanup(func() {
|
|
clearDownloadCancel("test-item")
|
|
runtime.clearActiveDownloadItemID()
|
|
})
|
|
|
|
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
req = runtime.bindDownloadCancelContext(req)
|
|
|
|
select {
|
|
case <-req.Context().Done():
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("Expected pre-cancelled request context to stay cancelled")
|
|
}
|
|
|
|
if req.Context().Err() == nil {
|
|
t.Fatal("Expected request context error for pre-cancelled item")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|
// Create extension with limited network permissions
|
|
ext := &loadedExtension{
|
|
ID: "test-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "test-ext",
|
|
Permissions: ExtensionPermissions{
|
|
Network: []string{"api.example.com"},
|
|
},
|
|
},
|
|
DataDir: t.TempDir(),
|
|
}
|
|
|
|
runtime := newExtensionRuntime(ext)
|
|
|
|
privateIPs := []string{
|
|
"http://localhost/admin",
|
|
"http://127.0.0.1/admin",
|
|
"http://192.168.1.1/admin",
|
|
"http://10.0.0.1/admin",
|
|
"http://172.16.0.1/admin",
|
|
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
|
"http://router.local/admin",
|
|
}
|
|
|
|
for _, url := range privateIPs {
|
|
err := runtime.validateDomain(url)
|
|
if err == nil {
|
|
t.Errorf("Expected private IP/host '%s' to be blocked", url)
|
|
}
|
|
}
|
|
|
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
|
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIsPrivateIP(t *testing.T) {
|
|
tests := []struct {
|
|
host string
|
|
expected bool
|
|
}{
|
|
{"localhost", true},
|
|
{"127.0.0.1", true},
|
|
{"127.0.0.2", true},
|
|
{"10.0.0.1", true},
|
|
{"10.255.255.255", true},
|
|
{"172.16.0.1", true},
|
|
{"172.31.255.255", true},
|
|
{"192.168.0.1", true},
|
|
{"192.168.255.255", true},
|
|
{"169.254.169.254", true},
|
|
{"router.local", true},
|
|
{"mydevice.local", true},
|
|
|
|
{"8.8.8.8", false},
|
|
{"1.1.1.1", false},
|
|
{"api.example.com", false},
|
|
{"google.com", false},
|
|
{"172.15.0.1", false},
|
|
{"172.32.0.1", false},
|
|
{"192.167.0.1", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := isPrivateIP(tt.host)
|
|
if result != tt.expected {
|
|
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
|
|
}
|
|
}
|
|
}
|