mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
- Add 16 Go supplement test files covering extension runtime, providers, health checks, lyrics, metadata, HTTP utils, library scan, and more - Add Dart model/utils test suite (test/models_and_utils_test.dart) - Update settings.g.dart with deduplicateDownloads serialization
747 lines
30 KiB
Go
747 lines
30 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
|
vm := goja.New()
|
|
runtime := &extensionRuntime{
|
|
extensionID: "auth-ext",
|
|
manifest: &ExtensionManifest{
|
|
Name: "auth-ext",
|
|
Description: "Auth extension",
|
|
Version: "1.0.0",
|
|
Permissions: ExtensionPermissions{
|
|
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
|
|
},
|
|
},
|
|
settings: map[string]interface{}{},
|
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Host {
|
|
case "token.example.com":
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)),
|
|
Request: req,
|
|
}, nil
|
|
case "api.example.com":
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Header: http.Header{"X-Test": []string{"yes"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)),
|
|
Request: req,
|
|
}, nil
|
|
default:
|
|
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
|
}
|
|
})},
|
|
vm: vm,
|
|
}
|
|
|
|
if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil {
|
|
t.Fatal("expected embedded credential error")
|
|
}
|
|
if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil {
|
|
t.Fatal("expected non-https auth URL error")
|
|
}
|
|
if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" {
|
|
t.Fatalf("summary = %q", got)
|
|
}
|
|
|
|
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("https://auth.example.com/login"),
|
|
vm.ToValue("app://callback"),
|
|
}}).Export().(map[string]interface{})
|
|
if openResult["success"] != true {
|
|
t.Fatalf("authOpenUrl = %#v", openResult)
|
|
}
|
|
if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" {
|
|
t.Fatalf("pending auth = %#v", pending)
|
|
}
|
|
if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) {
|
|
t.Fatalf("expected undefined code, got %v", code)
|
|
}
|
|
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
|
|
t.Fatal("authSetCode returned false")
|
|
}
|
|
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
|
|
t.Fatalf("code = %q", code)
|
|
}
|
|
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("expected authenticated runtime")
|
|
}
|
|
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
|
|
if tokens["access_token"] != "access" {
|
|
t.Fatalf("tokens = %#v", tokens)
|
|
}
|
|
|
|
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{})
|
|
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
|
|
t.Fatalf("pkce = %#v", pkce)
|
|
}
|
|
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
|
|
t.Fatalf("current pkce = %#v", current)
|
|
}
|
|
oauthConfig := map[string]interface{}{
|
|
"authUrl": "https://auth.example.com/oauth",
|
|
"clientId": "client",
|
|
"redirectUri": "app://callback",
|
|
"scope": "read",
|
|
"extraParams": map[string]interface{}{"prompt": "login"},
|
|
}
|
|
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
|
|
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
|
|
t.Fatalf("oauth = %#v", oauth)
|
|
}
|
|
tokenConfig := map[string]interface{}{
|
|
"tokenUrl": "https://token.example.com/token",
|
|
"clientId": "client",
|
|
"redirectUri": "app://callback",
|
|
"code": "abc",
|
|
}
|
|
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{})
|
|
if token["success"] != true || token["access_token"] != "access" {
|
|
t.Fatalf("token = %#v", token)
|
|
}
|
|
|
|
runtime.registerTextEncoderDecoder(vm)
|
|
runtime.registerURLClass(vm)
|
|
runtime.registerJSONGlobal(vm)
|
|
vm.Set("fetch", func(call goja.FunctionCall) goja.Value {
|
|
return runtime.fetchPolyfill(call)
|
|
})
|
|
vm.Set("atob", func(call goja.FunctionCall) goja.Value {
|
|
return runtime.atobPolyfill(call)
|
|
})
|
|
vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
|
|
return runtime.btoaPolyfill(call)
|
|
})
|
|
|
|
value, err := vm.RunString(`
|
|
var encoded = btoa("hello");
|
|
var decoded = atob(encoded);
|
|
var te = new TextEncoder();
|
|
var bytes = te.encode("hi");
|
|
var into = te.encodeInto("hi", []);
|
|
var td = new TextDecoder();
|
|
var text = td.decode(bytes);
|
|
var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base");
|
|
var params = new URLSearchParams("?x=1");
|
|
params.append("x", "2");
|
|
params.set("y", "3");
|
|
var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}});
|
|
JSON.stringify({
|
|
encoded: encoded,
|
|
decoded: decoded,
|
|
text: text,
|
|
read: into.read,
|
|
host: url.hostname,
|
|
first: url.searchParams.get("a"),
|
|
all: url.searchParams.getAll("a").length,
|
|
params: params.toString(),
|
|
ok: response.ok,
|
|
status: response.status,
|
|
jsonOk: response.json().ok,
|
|
bufferLen: response.arrayBuffer().length
|
|
});
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("polyfill script: %v", err)
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
|
|
t.Fatalf("decode polyfill result: %v", err)
|
|
}
|
|
if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true {
|
|
t.Fatalf("polyfill result = %#v", result)
|
|
}
|
|
|
|
blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm)
|
|
if blocked.Get("ok").ToBoolean() {
|
|
t.Fatal("expected blocked fetch")
|
|
}
|
|
runtime.authClear(goja.FunctionCall{})
|
|
if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("expected auth cleared")
|
|
}
|
|
}
|
|
|
|
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := &extensionStore{
|
|
registryURL: "https://registry.example.com/registry.json",
|
|
cacheDir: dir,
|
|
cacheTTL: time.Hour,
|
|
cache: &storeRegistry{
|
|
Version: 1,
|
|
UpdatedAt: "2026-05-04",
|
|
Extensions: []storeExtension{
|
|
{
|
|
ID: "coverage-ext",
|
|
Name: "coverage-ext",
|
|
DisplayNameAlt: "Coverage Extension",
|
|
Version: "2.0.0",
|
|
Description: "Metadata and lyrics provider",
|
|
DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext",
|
|
IconURLAlt: "https://registry.example.com/icon.png",
|
|
Category: CategoryMetadata,
|
|
Tags: []string{"metadata", "lyrics"},
|
|
Downloads: 10,
|
|
UpdatedAt: "2026-05-04",
|
|
MinAppVersionAlt: "4.5.0",
|
|
},
|
|
{
|
|
ID: "utility-ext",
|
|
Name: "utility-ext",
|
|
Version: "1.0.0",
|
|
Description: "Utility",
|
|
DownloadURL: "https://registry.example.com/utility.spotiflac-ext",
|
|
Category: CategoryUtility,
|
|
UpdatedAt: "2026-05-04",
|
|
},
|
|
},
|
|
},
|
|
cacheTime: time.Now(),
|
|
}
|
|
store.saveDiskCache()
|
|
loadedStore := &extensionStore{cacheDir: dir}
|
|
loadedStore.loadDiskCache()
|
|
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
|
|
t.Fatalf("loaded cache = %#v", loadedStore.cache)
|
|
}
|
|
if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" {
|
|
t.Fatalf("registry URL = %q", got)
|
|
}
|
|
store.setRegistryURL("https://registry.example.com/new.json")
|
|
if store.cache != nil {
|
|
t.Fatal("expected cache reset after registry URL change")
|
|
}
|
|
store.cache = loadedStore.cache
|
|
store.cacheTime = time.Now()
|
|
|
|
manager := getExtensionManager()
|
|
manager.mu.Lock()
|
|
if manager.extensions == nil {
|
|
manager.extensions = map[string]*loadedExtension{}
|
|
}
|
|
manager.extensions["coverage-ext"] = &loadedExtension{
|
|
ID: "coverage-ext",
|
|
Manifest: &ExtensionManifest{
|
|
Name: "coverage-ext",
|
|
DisplayName: "Coverage Extension",
|
|
Version: "1.0.0",
|
|
Description: "Installed",
|
|
Types: []ExtensionType{ExtensionTypeMetadataProvider},
|
|
},
|
|
Enabled: true,
|
|
}
|
|
manager.mu.Unlock()
|
|
defer func() {
|
|
manager.mu.Lock()
|
|
delete(manager.extensions, "coverage-ext")
|
|
manager.mu.Unlock()
|
|
}()
|
|
|
|
extensions, err := store.getExtensionsWithStatus(false)
|
|
if err != nil {
|
|
t.Fatalf("getExtensionsWithStatus: %v", err)
|
|
}
|
|
if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate {
|
|
t.Fatalf("extensions = %#v", extensions)
|
|
}
|
|
found, err := store.searchExtensions("lyrics", CategoryMetadata)
|
|
if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" {
|
|
t.Fatalf("search = %#v/%v", found, err)
|
|
}
|
|
all, err := store.searchExtensions("", "")
|
|
if err != nil || len(all) != 2 {
|
|
t.Fatalf("all search = %#v/%v", all, err)
|
|
}
|
|
if cats := store.getCategories(); len(cats) != 5 {
|
|
t.Fatalf("categories = %#v", cats)
|
|
}
|
|
if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") {
|
|
t.Fatal("string helper mismatch")
|
|
}
|
|
if err := requireHTTPSURL("http://example.com", "registry"); err == nil {
|
|
t.Fatal("expected HTTPS validation error")
|
|
}
|
|
if _, err := resolveRegistryURL(""); err == nil {
|
|
t.Fatal("expected empty registry URL error")
|
|
}
|
|
if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") {
|
|
t.Fatalf("resolved registry = %q/%v", resolved, err)
|
|
}
|
|
store.clearCache()
|
|
if store.cache != nil {
|
|
t.Fatal("expected cleared store cache")
|
|
}
|
|
|
|
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
|
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
|
|
t.Fatalf("SetDataDir: %v", err)
|
|
}
|
|
if err := settingsStore.Set("ext", "quality", "lossless"); err != nil {
|
|
t.Fatalf("settings Set: %v", err)
|
|
}
|
|
if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" {
|
|
t.Fatalf("settings Get = %#v/%v", value, err)
|
|
}
|
|
if _, err := settingsStore.Get("ext", "missing"); err == nil {
|
|
t.Fatal("expected missing setting error")
|
|
}
|
|
if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil {
|
|
t.Fatalf("settings SetAll: %v", err)
|
|
}
|
|
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
|
|
t.Fatalf("settings all = %#v", all)
|
|
}
|
|
if err := settingsStore.Remove("ext", "a"); err != nil {
|
|
t.Fatalf("settings Remove: %v", err)
|
|
}
|
|
if err := settingsStore.RemoveAll("ext"); err != nil {
|
|
t.Fatalf("settings RemoveAll: %v", err)
|
|
}
|
|
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
|
|
t.Fatalf("settings JSON = %q/%v", jsonText, err)
|
|
}
|
|
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
|
if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil {
|
|
t.Fatalf("reload settings: %v", err)
|
|
}
|
|
|
|
vm := goja.New()
|
|
runtime := &extensionRuntime{
|
|
extensionID: "storage-ext",
|
|
dataDir: filepath.Join(dir, "runtime"),
|
|
vm: vm,
|
|
storageFlushDelay: time.Hour,
|
|
}
|
|
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" {
|
|
t.Fatalf("storage fallback = %q", got)
|
|
}
|
|
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
|
t.Fatal("storageSet false")
|
|
}
|
|
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
|
t.Fatal("storageSet equal false")
|
|
}
|
|
loaded, err := runtime.loadStorage()
|
|
if err != nil || loaded["key"] == nil {
|
|
t.Fatalf("loadStorage = %#v/%v", loaded, err)
|
|
}
|
|
if err := runtime.flushStorageNow(); err != nil {
|
|
t.Fatalf("flushStorageNow: %v", err)
|
|
}
|
|
if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() {
|
|
t.Fatal("storageRemove false")
|
|
}
|
|
runtime.closeStorageFlusher()
|
|
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() {
|
|
t.Fatal("expected storageSet false after close")
|
|
}
|
|
|
|
credRuntime := &extensionRuntime{
|
|
extensionID: "cred-ext",
|
|
dataDir: filepath.Join(dir, "creds"),
|
|
vm: vm,
|
|
}
|
|
if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true {
|
|
t.Fatalf("credentialsStore = %#v", result)
|
|
}
|
|
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
|
|
t.Fatalf("credential = %q", got)
|
|
}
|
|
if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
|
t.Fatal("expected credential")
|
|
}
|
|
if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() {
|
|
t.Fatal("credentialsRemove false")
|
|
}
|
|
if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
|
t.Fatal("expected credential removed")
|
|
}
|
|
key, err := credRuntime.getEncryptionKey()
|
|
if err != nil {
|
|
t.Fatalf("getEncryptionKey: %v", err)
|
|
}
|
|
encrypted, err := encryptAES([]byte("plain"), key)
|
|
if err != nil {
|
|
t.Fatalf("encryptAES: %v", err)
|
|
}
|
|
decrypted, err := decryptAES(encrypted, key)
|
|
if err != nil || string(decrypted) != "plain" {
|
|
t.Fatalf("decryptAES = %q/%v", decrypted, err)
|
|
}
|
|
if _, err := decryptAES([]byte("short"), key); err == nil {
|
|
t.Fatal("expected short ciphertext error")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
|
|
vm := goja.New()
|
|
jar, _ := newSimpleCookieJar()
|
|
runtime := &extensionRuntime{
|
|
extensionID: "http-ext",
|
|
manifest: &ExtensionManifest{
|
|
Name: "http-ext",
|
|
Description: "HTTP extension",
|
|
Version: "1.0.0",
|
|
Permissions: ExtensionPermissions{
|
|
Network: []string{"api.example.com"},
|
|
},
|
|
},
|
|
vm: vm,
|
|
cookieJar: jar,
|
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
var body []byte
|
|
if req.Body != nil {
|
|
body, _ = io.ReadAll(req.Body)
|
|
}
|
|
header := make(http.Header)
|
|
header.Set("X-Method", req.Method)
|
|
if req.URL.Path == "/huge" {
|
|
return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil
|
|
}
|
|
return &http.Response{
|
|
StatusCode: 201,
|
|
Header: header,
|
|
Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))),
|
|
Request: req,
|
|
}, nil
|
|
})},
|
|
}
|
|
|
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
|
t.Fatalf("validateDomain allowed: %v", err)
|
|
}
|
|
for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} {
|
|
if err := runtime.validateDomain(rawURL); err == nil {
|
|
t.Fatalf("expected domain validation error for %s", rawURL)
|
|
}
|
|
}
|
|
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
|
|
t.Fatalf("httpGet = %#v", got)
|
|
}
|
|
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
|
|
t.Fatalf("httpPost = %#v", got)
|
|
}
|
|
requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}}
|
|
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") {
|
|
t.Fatalf("httpRequest = %#v", got)
|
|
}
|
|
for _, method := range []struct {
|
|
name string
|
|
call func(goja.FunctionCall) goja.Value
|
|
args []goja.Value
|
|
}{
|
|
{name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}},
|
|
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}},
|
|
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}},
|
|
} {
|
|
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) {
|
|
t.Fatalf("%s = %#v", method.name, got)
|
|
}
|
|
}
|
|
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
|
|
t.Fatalf("huge response = %#v", got)
|
|
}
|
|
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("expected cookies cleared")
|
|
}
|
|
|
|
if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 {
|
|
t.Fatal("missing string compare args should be zero")
|
|
}
|
|
if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 {
|
|
t.Fatal("expected exact string similarity")
|
|
}
|
|
if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true {
|
|
t.Fatal("expected duration match")
|
|
}
|
|
if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" {
|
|
t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String())
|
|
}
|
|
|
|
if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" {
|
|
t.Fatal("unexpected genre selection")
|
|
}
|
|
credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}}
|
|
if formatMusicBrainzArtistCredit(credits) != "A & B" {
|
|
t.Fatal("artist credit format mismatch")
|
|
}
|
|
releases := []musicBrainzRelease{
|
|
{Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}},
|
|
{Title: "Album", ArtistCredit: credits},
|
|
}
|
|
if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" {
|
|
t.Fatal("album artist selection mismatch")
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
|
vm := goja.New()
|
|
dir := t.TempDir()
|
|
SetAllowedDownloadDirs(nil)
|
|
defer SetAllowedDownloadDirs(nil)
|
|
|
|
fileBody := "chunk"
|
|
runtime := &extensionRuntime{
|
|
extensionID: "file-ext",
|
|
manifest: &ExtensionManifest{
|
|
Name: "file-ext",
|
|
Description: "File extension",
|
|
Version: "1.0.0",
|
|
Permissions: ExtensionPermissions{
|
|
File: true,
|
|
Network: []string{"files.example.com"},
|
|
},
|
|
},
|
|
dataDir: dir,
|
|
vm: vm,
|
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Header.Get("Range") == "" {
|
|
body := "downloaded"
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
ContentLength: int64(len(body)),
|
|
Request: req,
|
|
}, nil
|
|
}
|
|
rangeHeader := req.Header.Get("Range")
|
|
start, end := 0, len(fileBody)-1
|
|
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
|
|
start, end = 0, 1
|
|
}
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if end >= len(fileBody) {
|
|
end = len(fileBody) - 1
|
|
}
|
|
if start > len(fileBody) {
|
|
start = len(fileBody)
|
|
}
|
|
body := fileBody[start : end+1]
|
|
header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}}
|
|
return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
|
})},
|
|
}
|
|
runtime.downloadClient = runtime.httpClient
|
|
|
|
if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil {
|
|
t.Fatal("expected file permission error")
|
|
}
|
|
if _, err := runtime.validatePath("../escape.txt"); err == nil {
|
|
t.Fatal("expected sandbox escape error")
|
|
}
|
|
AddAllowedDownloadDir(dir)
|
|
absolutePath := filepath.Join(dir, "allowed.txt")
|
|
if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath {
|
|
t.Fatalf("absolute validatePath = %q/%v", got, err)
|
|
}
|
|
|
|
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{})
|
|
if write["success"] != true {
|
|
t.Fatalf("fileWrite = %#v", write)
|
|
}
|
|
if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() {
|
|
t.Fatal("expected written file to exist")
|
|
}
|
|
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{})
|
|
if read["data"] != "hello" {
|
|
t.Fatalf("fileRead = %#v", read)
|
|
}
|
|
|
|
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("nested/bytes.bin"),
|
|
vm.ToValue("4869"),
|
|
vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}),
|
|
}}).Export().(map[string]interface{})
|
|
if writeBytes["success"] != true {
|
|
t.Fatalf("fileWriteBytes = %#v", writeBytes)
|
|
}
|
|
appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("nested/bytes.bin"),
|
|
vm.ToValue([]interface{}{float64('!')}),
|
|
vm.ToValue(map[string]interface{}{"append": true}),
|
|
}}).Export().(map[string]interface{})
|
|
if appendBytes["success"] != true {
|
|
t.Fatalf("append fileWriteBytes = %#v", appendBytes)
|
|
}
|
|
readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("nested/bytes.bin"),
|
|
vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}),
|
|
}}).Export().(map[string]interface{})
|
|
if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 {
|
|
t.Fatalf("fileReadBytes = %#v", readBytes)
|
|
}
|
|
if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("nested/bad.bin"),
|
|
vm.ToValue("x"),
|
|
vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}),
|
|
}}).Export().(map[string]interface{}); bad["success"] != false {
|
|
t.Fatalf("expected append+offset failure, got %#v", bad)
|
|
}
|
|
if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("nested/bytes.bin"),
|
|
vm.ToValue(map[string]interface{}{"encoding": "bad"}),
|
|
}}).Export().(map[string]interface{}); bad["success"] != false {
|
|
t.Fatalf("expected bad encoding failure, got %#v", bad)
|
|
}
|
|
|
|
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{})
|
|
if copyResult["success"] != true {
|
|
t.Fatalf("fileCopy = %#v", copyResult)
|
|
}
|
|
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
|
if moveResult["success"] != true {
|
|
t.Fatalf("fileMove = %#v", moveResult)
|
|
}
|
|
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
|
if sizeResult["success"] != true || sizeResult["size"] != int64(3) {
|
|
t.Fatalf("fileGetSize = %#v", sizeResult)
|
|
}
|
|
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
|
if deleteResult["success"] != true {
|
|
t.Fatalf("fileDelete = %#v", deleteResult)
|
|
}
|
|
|
|
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("https://files.example.com/file"),
|
|
vm.ToValue("downloads/file.bin"),
|
|
}}).Export().(map[string]interface{})
|
|
if download["success"] != true {
|
|
t.Fatalf("fileDownload = %#v", download)
|
|
}
|
|
if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" {
|
|
t.Fatalf("downloaded data = %q/%v", data, err)
|
|
}
|
|
|
|
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
|
vm.ToValue("https://files.example.com/chunk"),
|
|
vm.ToValue("downloads/chunk.bin"),
|
|
vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}),
|
|
}}).Export().(map[string]interface{})
|
|
if chunked["success"] != true {
|
|
t.Fatalf("chunked fileDownload = %#v", chunked)
|
|
}
|
|
if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody {
|
|
t.Fatalf("chunked data = %q/%v", data, err)
|
|
}
|
|
|
|
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
|
|
t.Fatalf("expected missing download args error, got %#v", missing)
|
|
}
|
|
}
|
|
|
|
func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
|
|
vm := goja.New()
|
|
runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm}
|
|
|
|
if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" {
|
|
t.Fatal("expected sha256")
|
|
}
|
|
if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
|
t.Fatal("expected hmac sha256")
|
|
}
|
|
if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
|
t.Fatal("expected hmac sha256 base64")
|
|
}
|
|
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 {
|
|
t.Fatal("expected hmac sha1 bytes")
|
|
}
|
|
if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) {
|
|
t.Fatal("expected invalid JSON to return undefined")
|
|
}
|
|
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{})
|
|
if parsed["ok"] != true {
|
|
t.Fatalf("parseJSON = %#v", parsed)
|
|
}
|
|
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") {
|
|
t.Fatalf("stringifyJSON = %q", text)
|
|
}
|
|
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
|
if encrypted["success"] != true || encrypted["data"] == "" {
|
|
t.Fatalf("cryptoEncrypt = %#v", encrypted)
|
|
}
|
|
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
|
if decrypted["success"] != true || decrypted["data"] != "plain" {
|
|
t.Fatalf("cryptoDecrypt = %#v", decrypted)
|
|
}
|
|
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false {
|
|
t.Fatalf("expected bad decrypt failure, got %#v", bad)
|
|
}
|
|
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{})
|
|
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
|
|
t.Fatalf("cryptoGenerateKey = %#v", key)
|
|
}
|
|
if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" {
|
|
t.Fatal("expected user agents")
|
|
}
|
|
SetAppVersion("9.9.9")
|
|
if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" {
|
|
t.Fatal("appVersion mismatch")
|
|
}
|
|
if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() {
|
|
t.Fatal("zero sleep should succeed")
|
|
}
|
|
|
|
itemID := "utils-item"
|
|
runtime.setActiveDownloadItemID(itemID)
|
|
initDownloadCancel(itemID)
|
|
if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("item should not be cancelled yet")
|
|
}
|
|
runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}})
|
|
cancelDownload(itemID)
|
|
if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("item should be cancelled")
|
|
}
|
|
clearDownloadCancel(itemID)
|
|
runtime.clearActiveDownloadItemID()
|
|
|
|
requestID := "utils-request"
|
|
runtime.setActiveRequestID(requestID)
|
|
initExtensionRequestCancel(requestID)
|
|
if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("request should not be cancelled yet")
|
|
}
|
|
cancelExtensionRequest(requestID)
|
|
if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
t.Fatal("request should be cancelled")
|
|
}
|
|
clearExtensionRequestCancel(requestID)
|
|
runtime.clearActiveRequestID()
|
|
|
|
if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" {
|
|
t.Fatalf("formatLogArgs = %q", msg)
|
|
}
|
|
runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}})
|
|
runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}})
|
|
runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}})
|
|
runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}})
|
|
if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") {
|
|
t.Fatalf("sanitize wrapper = %q", clean)
|
|
}
|
|
}
|