From 4fc837b94c07e6e0984f318b41b5c87203698d74 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:03:29 +0700 Subject: [PATCH] fix(extensions): preserve verification on availability errors --- ...xtension_availability_verification_test.go | 57 +++++++++++++++++++ go_backend/extension_provider_wrapper.go | 45 +++++++++++---- 2 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 go_backend/extension_availability_verification_test.go diff --git a/go_backend/extension_availability_verification_test.go b/go_backend/extension_availability_verification_test.go new file mode 100644 index 00000000..a2acd576 --- /dev/null +++ b/go_backend/extension_availability_verification_test.go @@ -0,0 +1,57 @@ +package gobackend + +import ( + "strings" + "testing" + + "github.com/dop251/goja" +) + +func TestAvailabilityPreservesCanonicalVerificationOnFailure(t *testing.T) { + for _, script := range []string{ + `throw new Error("VERIFY_REQUIRED");`, + `return null;`, + `return undefined;`, + `return {available:false};`, + } { + t.Run(script, func(t *testing.T) { + ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + t.Cleanup(func() { teardownExtension(ext) }) + if err := ext.ensureRuntimeReady(); err != nil { + t.Fatal(err) + } + if err := ext.VM.Set("requireChallenge", func(goja.FunctionCall) goja.Value { + ext.runtime.noteVerificationRequired("https://example.test/challenge") + return goja.Undefined() + }); err != nil { + t.Fatal(err) + } + if _, err := ext.VM.RunString(`extension.checkAvailability = function(){ requireChallenge(); ` + script + ` };`); err != nil { + t.Fatal(err) + } + _, err := newExtensionProviderWrapper(ext).CheckAvailabilityForItemID("", "Song", "Artist", "", "", "", "", 180000, "") + if err == nil || classifyDownloadErrorType(err.Error()) != "verification_required" || !strings.Contains(err.Error(), ext.ID) { + t.Fatalf("canonical verification not preserved: %v", err) + } + if ext.runtime.consumeVerificationRequired() != "" { + t.Fatal("verification evidence leaked into the next call") + } + }) + } +} + +func TestAvailabilityDoesNotPromoteUntrustedOrStaleVerification(t *testing.T) { + ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + t.Cleanup(func() { teardownExtension(ext) }) + if err := ext.ensureRuntimeReady(); err != nil { + t.Fatal(err) + } + if _, err := ext.VM.RunString(`extension.checkAvailability = function(){ throw new Error("VERIFY_REQUIRED"); };`); err != nil { + t.Fatal(err) + } + ext.runtime.noteVerificationRequired("https://example.test/stale-challenge") + _, err := newExtensionProviderWrapper(ext).CheckAvailabilityForItemID("", "Song", "Artist", "", "", "", "", 180000, "") + if err == nil || classifyDownloadErrorType(err.Error()) == "verification_required" { + t.Fatalf("untrusted exception inherited a previous challenge: %v", err) + } +} diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go index 893f9507..e015b30a 100644 --- a/go_backend/extension_provider_wrapper.go +++ b/go_backend/extension_provider_wrapper.go @@ -529,21 +529,47 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a availabilityOptions["track"] = trackContexts[0] } + var availabilityRuntime *extensionRuntime + consumeVerificationError := func() error { + if availabilityRuntime != nil && availabilityRuntime.consumeVerificationRequired() != "" { + return fmt.Errorf( + "verification_required: extension '%s' needs signed-session verification", + p.extension.ID, + ) + } + return nil + } + return callExtension(p, extCallOpts{ perfName: "checkAvailability", - invoke: extensionMethodInvocation("checkAvailability", isrc, trackName, artistName, availabilityOptions), - timeout: DefaultJSTimeout, - itemID: itemID, + invoke: func(vm *goja.Runtime) (goja.Value, error) { + result, err := invokeExtensionMethod(vm, "checkAvailability", isrc, trackName, artistName, availabilityOptions) + // A thrown JS error must preserve the same canonical runtime evidence + // as a returned unavailable result. Cancellation and timeout still + // take precedence in callExtension. + if err != nil { + if verificationErr := consumeVerificationError(); verificationErr != nil { + return nil, verificationErr + } + } + return result, err + }, + timeout: DefaultJSTimeout, + itemID: itemID, beforeRun: func() func() { // Drop any stale flag so the post-run check below only sees // verification requested by THIS call. - if p.extension.runtime != nil { - p.extension.runtime.consumeVerificationRequired() + availabilityRuntime = p.extension.runtime + if availabilityRuntime != nil { + availabilityRuntime.consumeVerificationRequired() } return nil }, }, func(perf *extensionCallPerf, result goja.Value) (*ExtAvailabilityResult, error) { if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + if err := consumeVerificationError(); err != nil { + return nil, err + } return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil } parseStartedAt := time.Now() @@ -555,12 +581,9 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a // "not available", which would silently skip this provider's // challenge; surface it as an error so the fallback loop pauses and // opens the challenge instead. - if !availability.Available && p.extension.runtime != nil { - if p.extension.runtime.consumeVerificationRequired() != "" { - return nil, fmt.Errorf( - "verification_required: extension '%s' needs signed-session verification", - p.extension.ID, - ) + if !availability.Available { + if err := consumeVerificationError(); err != nil { + return nil, err } } return &availability, nil