mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(session): honor canonical gateway error contracts
This commit is contained in:
@@ -108,6 +108,15 @@ The behavior flags currently supported are `skipMetadataEnrichment`,
|
||||
extension-specific behavior must be added as a generic manifest capability;
|
||||
the host must not branch on a particular provider ID.
|
||||
|
||||
Extensions that depend on canonical gateway/provider error ownership should
|
||||
declare `requiredRuntimeFeatures: ["signedSession@2"]`. Version 2 only mutates
|
||||
gateway session state for the exact `SESSION_INVALID/bootstrap_session` or
|
||||
`VERIFY_REQUIRED/verify` contracts, exposes canonical error fields to JS, and
|
||||
applies bounded `Retry-After` handling to retryable `PROVIDER_UNAVAILABLE`
|
||||
responses. `403 REQUEST_AUTH_INVALID` preserves the session and only retries
|
||||
once when a newer session generation is already available. BYOA
|
||||
reauthentication remains a separate provider action.
|
||||
|
||||
Do not use legacy spellings such as `display_name`, `types`,
|
||||
`permissions.network.domains`, or an object for `permissions.network`.
|
||||
|
||||
|
||||
@@ -435,18 +435,20 @@ func classifyDownloadErrorType(msg string) string {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "verify_required") ||
|
||||
strings.Contains(lowerMsg, "verification_required") ||
|
||||
strings.Contains(lowerMsg, "verification required") ||
|
||||
strings.Contains(lowerMsg, "needs verification") ||
|
||||
} else if strings.Contains(lowerMsg, "verification_required") ||
|
||||
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session expired") ||
|
||||
strings.Contains(lowerMsg, "unauthorized") ||
|
||||
strings.Contains(lowerMsg, "precondition required") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "401") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "428") {
|
||||
strings.Contains(lowerMsg, "signed session expired") {
|
||||
return "verification_required"
|
||||
} else if strings.Contains(lowerMsg, "byoa_provider_reauth_required") ||
|
||||
strings.Contains(lowerMsg, "reauth_provider") {
|
||||
return "provider_reauth_required"
|
||||
} else if strings.Contains(lowerMsg, "request_auth_invalid") {
|
||||
return "request_auth_invalid"
|
||||
} else if strings.Contains(lowerMsg, "provider_auth_failed") {
|
||||
return "provider_auth_failed"
|
||||
} else if strings.Contains(lowerMsg, "provider_unavailable") {
|
||||
return "provider_unavailable"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
|
||||
@@ -33,9 +33,8 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
|
||||
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
cases := []string{
|
||||
"HTTP 401 for /tickets",
|
||||
"HTTP status 428: precondition required",
|
||||
"Verification required",
|
||||
"verification_required: canonical gateway challenge",
|
||||
"signed session expired",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||
@@ -44,6 +43,36 @@ func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrorClassificationDoesNotInferVerificationFromHTTPStatus(t *testing.T) {
|
||||
cases := []string{
|
||||
"HTTP 401 for /tickets",
|
||||
"HTTP 403 forbidden",
|
||||
"HTTP status 428: precondition required",
|
||||
"Provider returned unauthorized",
|
||||
"VERIFY_REQUIRED without canonical origin and action",
|
||||
"Verification required without a typed contract",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyDownloadErrorType(tc); got == "verification_required" {
|
||||
t.Fatalf("classifyDownloadErrorType(%q) inferred verification from an ambiguous status", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrorClassificationPreservesProviderContracts(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"PROVIDER_AUTH_FAILED": "provider_auth_failed",
|
||||
"PROVIDER_UNAVAILABLE": "provider_unavailable",
|
||||
"REQUEST_AUTH_INVALID": "request_auth_invalid",
|
||||
"BYOA_PROVIDER_REAUTH_REQUIRED action": "provider_reauth_required",
|
||||
}
|
||||
for message, want := range tests {
|
||||
if got := classifyDownloadErrorType(message); got != want {
|
||||
t.Fatalf("classifyDownloadErrorType(%q) = %q, want %q", message, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputStorageWriteFailureDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -251,7 +251,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
||||
}
|
||||
|
||||
var supportedRuntimeFeatures = map[string]int{
|
||||
"signedSession": 1,
|
||||
"signedSession": 2,
|
||||
"sessionRefresh": 1,
|
||||
"sessionGrant": 1,
|
||||
"globalAction": 1,
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestValidateManifestGates(t *testing.T) {
|
||||
SetAppVersion("4.5.0")
|
||||
|
||||
pass := &ExtensionManifest{
|
||||
RequiredRuntimeFeatures: []string{"signedSession@1", "sessionGrant"},
|
||||
RequiredRuntimeFeatures: []string{"signedSession@2", "sessionGrant"},
|
||||
}
|
||||
if err := validateManifestGates(pass); err != nil {
|
||||
t.Fatalf("supported features should pass: %v", err)
|
||||
|
||||
@@ -548,7 +548,7 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
|
||||
if !availability.Available && p.extension.runtime != nil {
|
||||
if p.extension.runtime.consumeVerificationRequired() != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"VERIFY_REQUIRED: extension '%s' needs signed-session verification",
|
||||
"verification_required: extension '%s' needs signed-session verification",
|
||||
p.extension.ID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ const signedSessionRefreshSkew = time.Hour
|
||||
const (
|
||||
signedSessionExchangeMaxAttempts = 3
|
||||
signedSessionMaxRetryAfter = 5 * time.Minute
|
||||
signedSessionMaxSessionRetries = 2
|
||||
signedSessionMaxProviderRetries = 2
|
||||
signedSessionProviderRetryDelay = time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -35,6 +38,7 @@ var (
|
||||
pendingSignedSessionGrantsMu sync.Mutex
|
||||
signedSessionCoordinators sync.Map
|
||||
signedSessionRetryWait = time.Sleep
|
||||
signedSessionProviderWait = sleepRetry
|
||||
)
|
||||
|
||||
// signedSessionCoordinator serializes authentication state for every runtime
|
||||
@@ -106,6 +110,19 @@ type signedSessionExchangeResponse struct {
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
}
|
||||
|
||||
// signedSessionErrorContract is the gateway-owned error envelope. Decisions
|
||||
// that mutate authentication state must use these fields together with the
|
||||
// HTTP status; a provider response must never be able to masquerade as a
|
||||
// gateway session failure merely by returning 401 or 403 upstream.
|
||||
type signedSessionErrorContract struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
}
|
||||
|
||||
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||
if config == nil {
|
||||
return SignedSessionConfig{}
|
||||
@@ -285,6 +302,64 @@ func sameSignedSession(a, b *signedSessionRecord) bool {
|
||||
a.SessionSecret == b.SessionSecret
|
||||
}
|
||||
|
||||
func parseSignedSessionErrorContract(body []byte) (signedSessionErrorContract, bool) {
|
||||
var contract signedSessionErrorContract
|
||||
if len(body) == 0 || json.Unmarshal(body, &contract) != nil {
|
||||
return signedSessionErrorContract{}, false
|
||||
}
|
||||
contract.Error = strings.TrimSpace(contract.Error)
|
||||
contract.Code = strings.ToUpper(strings.TrimSpace(contract.Code))
|
||||
contract.Origin = strings.ToLower(strings.TrimSpace(contract.Origin))
|
||||
contract.Action = strings.ToLower(strings.TrimSpace(contract.Action))
|
||||
if contract.RetryAfterSeconds < 0 {
|
||||
contract.RetryAfterSeconds = 0
|
||||
}
|
||||
return contract, contract.Code != "" || contract.Origin != "" || contract.Action != ""
|
||||
}
|
||||
|
||||
func signedSessionGatewayAction(statusCode int, contract signedSessionErrorContract) string {
|
||||
if contract.Origin != "gateway" {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case statusCode == http.StatusUnauthorized &&
|
||||
contract.Code == "SESSION_INVALID" &&
|
||||
contract.Action == "bootstrap_session":
|
||||
return "bootstrap_session"
|
||||
case statusCode == http.StatusPreconditionRequired &&
|
||||
contract.Code == "VERIFY_REQUIRED" &&
|
||||
contract.Action == "verify":
|
||||
return "verify"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func signedSessionProviderUnavailable(statusCode int, contract signedSessionErrorContract) bool {
|
||||
return statusCode == http.StatusServiceUnavailable &&
|
||||
contract.Origin == "provider" &&
|
||||
contract.Code == "PROVIDER_UNAVAILABLE" &&
|
||||
contract.Retryable
|
||||
}
|
||||
|
||||
func signedSessionRequestAuthInvalid(statusCode int, contract signedSessionErrorContract) bool {
|
||||
return statusCode == http.StatusForbidden &&
|
||||
contract.Origin == "gateway" &&
|
||||
contract.Code == "REQUEST_AUTH_INVALID" &&
|
||||
contract.Action == ""
|
||||
}
|
||||
|
||||
func signedSessionProviderRetryDuration(resp *http.Response, contract signedSessionErrorContract) time.Duration {
|
||||
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
|
||||
return retryAfter
|
||||
}
|
||||
if contract.RetryAfterSeconds > 0 {
|
||||
maxSeconds := int(maxRetryAfterDelay / time.Second)
|
||||
return time.Duration(min(contract.RetryAfterSeconds, maxSeconds)) * time.Second
|
||||
}
|
||||
return signedSessionProviderRetryDelay
|
||||
}
|
||||
|
||||
// preflightSignedSession prepares a signed session before download metadata
|
||||
// enrichment starts. A fresh pending challenge is reused, while bootstrap
|
||||
// responses that can issue a session silently are accepted without prompting
|
||||
@@ -558,10 +633,14 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
}
|
||||
coordinator.mu.Unlock()
|
||||
|
||||
// A request that loses a race with a successful grant exchange may return
|
||||
// 401 using the old secret. Reload and retry with the newer shared session;
|
||||
// never let the stale response erase that replacement session.
|
||||
for sessionAttempt := 0; sessionAttempt < 3; sessionAttempt++ {
|
||||
// A request that loses a race with a successful grant exchange may return a
|
||||
// canonical SESSION_INVALID response for the old secret. Reload and retry
|
||||
// with the newer shared session; never let that stale response erase its
|
||||
// replacement. Provider retries have an independent, bounded budget.
|
||||
sessionRetries := 0
|
||||
providerRetries := 0
|
||||
requestAuthRetryUsed := false
|
||||
for {
|
||||
resp, respBody, respHeaders, requestErr := r.doSignedSessionRequest(
|
||||
config,
|
||||
record,
|
||||
@@ -573,8 +652,59 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
if requestErr != nil {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": requestErr.Error()})
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized &&
|
||||
resp.StatusCode != http.StatusPreconditionRequired {
|
||||
contract, _ := parseSignedSessionErrorContract(respBody)
|
||||
|
||||
if signedSessionProviderUnavailable(resp.StatusCode, contract) {
|
||||
if providerRetries >= signedSessionMaxProviderRetries {
|
||||
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||
}
|
||||
providerRetries++
|
||||
delay := signedSessionProviderRetryDuration(resp, contract)
|
||||
LogWarn(
|
||||
"SignedSession",
|
||||
"Provider temporarily unavailable for extension %s; retrying in %s (attempt %d/%d)",
|
||||
r.extensionID,
|
||||
delay,
|
||||
providerRetries+1,
|
||||
signedSessionMaxProviderRetries+1,
|
||||
)
|
||||
if waitErr := signedSessionProviderWait(resp.Request.Context(), delay); waitErr != nil {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": waitErr.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if signedSessionRequestAuthInvalid(resp.StatusCode, contract) {
|
||||
coordinator.mu.Lock()
|
||||
latest, loadErr := r.loadSignedSession(config)
|
||||
if loadErr != nil {
|
||||
coordinator.mu.Unlock()
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
|
||||
}
|
||||
if !requestAuthRetryUsed &&
|
||||
signedSessionRecordIsUsable(latest) &&
|
||||
!sameSignedSession(latest, record) {
|
||||
requestAuthRetryUsed = true
|
||||
record = latest
|
||||
coordinator.mu.Unlock()
|
||||
LogDebug(
|
||||
"SignedSession",
|
||||
"Retrying stale REQUEST_AUTH_INVALID for extension %s with the current session generation",
|
||||
r.extensionID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
coordinator.mu.Unlock()
|
||||
LogWarn(
|
||||
"SignedSession",
|
||||
"REQUEST_AUTH_INVALID for extension %s on the current session generation; preserving session state",
|
||||
r.extensionID,
|
||||
)
|
||||
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||
}
|
||||
|
||||
gatewayAction := signedSessionGatewayAction(resp.StatusCode, contract)
|
||||
if gatewayAction == "" {
|
||||
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||
}
|
||||
|
||||
@@ -586,16 +716,24 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
}
|
||||
if signedSessionRecordIsUsable(latest) &&
|
||||
!sameSignedSession(latest, record) {
|
||||
if sessionRetries >= signedSessionMaxSessionRetries {
|
||||
coordinator.mu.Unlock()
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": "signed-session retry limit reached"})
|
||||
}
|
||||
sessionRetries++
|
||||
record = latest
|
||||
coordinator.mu.Unlock()
|
||||
LogDebug(
|
||||
"SignedSession",
|
||||
"Discarding stale 401 for extension %s and retrying with the exchanged session",
|
||||
"Discarding stale %s response for extension %s and retrying with the exchanged session",
|
||||
contract.Code,
|
||||
r.extensionID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if sameSignedSession(latest, record) {
|
||||
// VERIFY_REQUIRED is an explicit challenge request, not a revocation.
|
||||
// Only SESSION_INVALID is allowed to clear the current gateway session.
|
||||
if gatewayAction == "bootstrap_session" && sameSignedSession(latest, record) {
|
||||
latest.SessionID = ""
|
||||
latest.SessionSecret = ""
|
||||
latest.ExpiresAt = ""
|
||||
@@ -607,17 +745,34 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
authURL, verificationErr := r.startSignedSessionVerificationLocked(
|
||||
config,
|
||||
coordinator,
|
||||
"signed-fetch-reauth",
|
||||
"signed-fetch-"+gatewayAction,
|
||||
)
|
||||
coordinator.mu.Unlock()
|
||||
if authURL != "" {
|
||||
coordinator.mu.Unlock()
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
} else if verificationErr != nil {
|
||||
coordinator.mu.Unlock()
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
|
||||
}
|
||||
|
||||
// Bootstrap may silently issue a replacement session instead of a
|
||||
// challenge. Retry the original operation with that generation.
|
||||
bootstrapped, loadErr := r.loadSignedSession(config)
|
||||
if loadErr != nil {
|
||||
coordinator.mu.Unlock()
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
|
||||
}
|
||||
if signedSessionRecordIsUsable(bootstrapped) &&
|
||||
!sameSignedSession(bootstrapped, record) &&
|
||||
sessionRetries < signedSessionMaxSessionRetries {
|
||||
sessionRetries++
|
||||
record = bootstrapped
|
||||
coordinator.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
coordinator.mu.Unlock()
|
||||
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||
}
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": "signed-session retry limit reached"})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionResponseValue(
|
||||
@@ -625,15 +780,28 @@ func (r *extensionRuntime) signedSessionResponseValue(
|
||||
respBody []byte,
|
||||
respHeaders map[string]any,
|
||||
) goja.Value {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
contract, hasContract := parseSignedSessionErrorContract(respBody)
|
||||
retryAfterSeconds := signedSessionRetryAfterSeconds(resp)
|
||||
if retryAfterSeconds <= 0 && contract.RetryAfterSeconds > 0 {
|
||||
retryAfterSeconds = contract.RetryAfterSeconds
|
||||
}
|
||||
result := map[string]any{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(respBody),
|
||||
"headers": respHeaders,
|
||||
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
||||
})
|
||||
"retryAfterSeconds": retryAfterSeconds,
|
||||
}
|
||||
if hasContract {
|
||||
result["error"] = contract.Error
|
||||
result["code"] = contract.Code
|
||||
result["origin"] = contract.Origin
|
||||
result["action"] = contract.Action
|
||||
result["retryable"] = contract.Retryable
|
||||
}
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -582,6 +583,24 @@ func TestSignedSessionRetryAfterSeconds(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignedSessionProviderRetryDuration(t *testing.T) {
|
||||
t.Run("header is authoritative", func(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{"Retry-After": []string{"10"}}}
|
||||
contract := signedSessionErrorContract{RetryAfterSeconds: 30}
|
||||
if got := signedSessionProviderRetryDuration(resp, contract); got != 10*time.Second {
|
||||
t.Fatalf("retry duration = %s, want 10s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("body fallback is bounded", func(t *testing.T) {
|
||||
resp := &http.Response{Header: make(http.Header)}
|
||||
contract := signedSessionErrorContract{RetryAfterSeconds: int(^uint(0) >> 1)}
|
||||
if got := signedSessionProviderRetryDuration(resp, contract); got != maxRetryAfterDelay {
|
||||
t.Fatalf("retry duration = %s, want cap %s", got, maxRetryAfterDelay)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeSignedSessionRecordScope(t *testing.T) {
|
||||
config := SignedSessionConfig{Namespace: "Tidal", BaseURL: "https://a.example.com", AppVersion: "1.0", Platform: "mobile"}
|
||||
|
||||
@@ -634,6 +653,27 @@ func newSignedSessionTestRuntime(t *testing.T, extensionID string, transport rou
|
||||
}
|
||||
}
|
||||
|
||||
func saveUsableSignedSession(
|
||||
t *testing.T,
|
||||
runtime *extensionRuntime,
|
||||
config SignedSessionConfig,
|
||||
sessionID string,
|
||||
) SignedSessionConfig {
|
||||
t.Helper()
|
||||
resolved := signedSessionConfigWithDefaults(&config)
|
||||
record, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.SessionID = sessionID
|
||||
record.SessionSecret = "secret-" + sessionID
|
||||
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
if err := runtime.saveSignedSession(resolved, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func TestSignedSessionFilePathDeterminism(t *testing.T) {
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
|
||||
|
||||
@@ -878,7 +918,7 @@ func TestSignedSessionFetchUnauthenticatedTriggersVerification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) {
|
||||
func TestSignedSessionFetchRevokesSessionOnCanonicalSessionInvalid(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
@@ -888,7 +928,14 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) {
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway","action":"bootstrap_session"}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
})
|
||||
|
||||
@@ -911,7 +958,7 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) {
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("GET"), runtime.vm.ToValue("/tracks/search")}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["ok"] != false || result["needsVerification"] != true {
|
||||
t.Fatalf("expected a verification-required response after 401, got %+v", result)
|
||||
t.Fatalf("expected verification after canonical SESSION_INVALID, got %+v", result)
|
||||
}
|
||||
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
@@ -919,10 +966,459 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reloaded.SessionID != "" || reloaded.SessionSecret != "" {
|
||||
t.Fatalf("expected session to be wiped after 401, got %+v", reloaded)
|
||||
t.Fatalf("expected session to be wiped after SESSION_INVALID, got %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchRetriesAfterSilentSessionBootstrap(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/bootstrap"):
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
SessionID: "sess-replacement",
|
||||
SessionSecret: "secret-replacement",
|
||||
ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.Header.Get("X-Sig-Session") == "sess-replacement":
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"ticket_id":"fresh"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway","action":"bootstrap_session"}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "silent-bootstrap", transport)
|
||||
config := SignedSessionConfig{Namespace: "silent-bootstrap", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-old")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("POST"),
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["ok"] != true || calls != 3 {
|
||||
t.Fatalf("signed request was not retried after silent bootstrap: calls=%d result=%+v", calls, result)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-replacement" || reloaded.SessionSecret != "secret-replacement" {
|
||||
t.Fatalf("replacement session was not persisted: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchDoesNotMutateSessionForNonCanonicalAuthStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "bare 401",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
body: `{}`,
|
||||
},
|
||||
{
|
||||
name: "provider-origin 401",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
body: `{"error":"Unauthorized","code":"PROVIDER_AUTH_FAILED","origin":"provider"}`,
|
||||
},
|
||||
{
|
||||
name: "gateway 401 missing action",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
body: `{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway"}`,
|
||||
},
|
||||
{
|
||||
name: "bare 428",
|
||||
statusCode: http.StatusPreconditionRequired,
|
||||
body: `{"error":"VERIFY_REQUIRED"}`,
|
||||
},
|
||||
{
|
||||
name: "request auth 403 with forbidden action",
|
||||
statusCode: http.StatusForbidden,
|
||||
body: `{"error":"Forbidden","code":"REQUEST_AUTH_INVALID","origin":"gateway","action":"bootstrap_session"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
StatusCode: tc.statusCode,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(tc.body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "strict-contract-"+strings.ReplaceAll(tc.name, " ", "-"), transport)
|
||||
config := SignedSessionConfig{Namespace: runtime.extensionID, BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-safe")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("GET"),
|
||||
runtime.vm.ToValue("/tracks/search"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["needsVerification"] == true {
|
||||
t.Fatalf("non-canonical response triggered verification: %+v", result)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("network calls = %d, want no bootstrap or retry", calls)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-safe" || reloaded.SessionSecret != "secret-sess-safe" {
|
||||
t.Fatalf("non-canonical response mutated the session: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if strings.Contains(req.URL.Path, "/bootstrap") {
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
AuthURL: "https://auth.example.com/verify",
|
||||
})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusPreconditionRequired,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"VERIFY_REQUIRED","code":"VERIFY_REQUIRED","origin":"gateway","action":"verify"}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "canonical-verify", transport)
|
||||
config := SignedSessionConfig{Namespace: "canonical-verify", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-verified")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("GET"),
|
||||
runtime.vm.ToValue("/tracks/search"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["needsVerification"] != true || result["auth_url"] != "https://auth.example.com/verify" {
|
||||
t.Fatalf("canonical VERIFY_REQUIRED did not open verification: %+v", result)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("network calls = %d, want signed request plus bootstrap", calls)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-verified" || reloaded.SessionSecret != "secret-sess-verified" {
|
||||
t.Fatalf("VERIFY_REQUIRED cleared the valid gateway session: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchRequestAuthInvalidPreservesSession(t *testing.T) {
|
||||
t.Run("current generation returns the error without bootstrap", func(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Forbidden","code":"REQUEST_AUTH_INVALID","origin":"gateway","retryable":false}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "request-auth-current", transport)
|
||||
config := SignedSessionConfig{Namespace: "request-auth-current", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-request-auth")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("POST"),
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["code"] != "REQUEST_AUTH_INVALID" || result["needsVerification"] == true {
|
||||
t.Fatalf("REQUEST_AUTH_INVALID was not returned as a non-verification error: %+v", result)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("current generation request was retried or bootstrapped: calls=%d", calls)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-request-auth" || reloaded.SessionSecret != "secret-sess-request-auth" {
|
||||
t.Fatalf("REQUEST_AUTH_INVALID mutated the current session: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale generation retries once with the current session", func(t *testing.T) {
|
||||
oldRequestStarted := make(chan struct{})
|
||||
releaseOldRequest := make(chan struct{})
|
||||
var requestCalls atomic.Int32
|
||||
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/session/exchange"):
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
SessionID: "sess-request-new",
|
||||
SessionSecret: "secret-request-new",
|
||||
ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
case strings.HasSuffix(req.URL.Path, "/tickets"):
|
||||
requestCalls.Add(1)
|
||||
if req.Header.Get("X-Sig-Session") == "sess-request-old" {
|
||||
close(oldRequestStarted)
|
||||
<-releaseOldRequest
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Forbidden","code":"REQUEST_AUTH_INVALID","origin":"gateway","retryable":false}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"ticket_id":"fresh"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
config := &SignedSessionConfig{Namespace: "request-auth-shared", BaseURL: "https://auth.example.com"}
|
||||
newRuntime := func(extensionID string) *extensionRuntime {
|
||||
return &extensionRuntime{
|
||||
extensionID: extensionID,
|
||||
manifest: &ExtensionManifest{
|
||||
Name: extensionID,
|
||||
SignedSession: config,
|
||||
},
|
||||
dataDir: filepath.Join(root, extensionID),
|
||||
vm: goja.New(),
|
||||
httpClient: &http.Client{Transport: transport},
|
||||
}
|
||||
}
|
||||
runtimeA := newRuntime("request-auth-a")
|
||||
runtimeB := newRuntime("request-auth-b")
|
||||
resolved := saveUsableSignedSession(t, runtimeA, *config, "sess-request-old")
|
||||
|
||||
resultCh := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtimeA.vm.ToValue("POST"),
|
||||
runtimeA.vm.ToValue("/tickets"),
|
||||
}}
|
||||
resultCh <- runtimeA.signedSessionFetch(call).Export().(map[string]any)
|
||||
}()
|
||||
<-oldRequestStarted
|
||||
if err := runtimeB.exchangeSignedSessionGrant("grant-request-auth"); err != nil {
|
||||
t.Fatalf("exchange grant: %v", err)
|
||||
}
|
||||
close(releaseOldRequest)
|
||||
|
||||
result := <-resultCh
|
||||
if result["ok"] != true || requestCalls.Load() != 2 {
|
||||
t.Fatalf("stale request was not rebuilt once: calls=%d result=%+v", requestCalls.Load(), result)
|
||||
}
|
||||
reloaded, err := runtimeA.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-request-new" || reloaded.SessionSecret != "secret-request-new" {
|
||||
t.Fatalf("stale REQUEST_AUTH_INVALID changed the new session: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
t.Run("provider authentication failure passes through without retry", func(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Provider authentication failed","code":"PROVIDER_AUTH_FAILED","origin":"provider","retryable":false}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "provider-auth-failed", transport)
|
||||
config := SignedSessionConfig{Namespace: "provider-auth-failed", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-auth")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("POST"),
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["code"] != "PROVIDER_AUTH_FAILED" || result["origin"] != "provider" {
|
||||
t.Fatalf("provider contract was not exposed to the extension: %+v", result)
|
||||
}
|
||||
if result["needsVerification"] == true || calls != 1 {
|
||||
t.Fatalf("provider auth failure triggered verification or retry: calls=%d result=%+v", calls, result)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-provider-auth" {
|
||||
t.Fatalf("provider auth failure cleared the gateway session: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temporary provider failure retries with Retry-After", func(t *testing.T) {
|
||||
previousWait := signedSessionProviderWait
|
||||
var waits []time.Duration
|
||||
signedSessionProviderWait = func(_ context.Context, delay time.Duration) error {
|
||||
waits = append(waits, delay)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { signedSessionProviderWait = previousWait })
|
||||
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls <= signedSessionMaxProviderRetries {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: http.Header{"Retry-After": []string{"10"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_after_seconds":30}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "provider-unavailable", transport)
|
||||
config := SignedSessionConfig{Namespace: "provider-unavailable", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-retry")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("POST"),
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["ok"] != true || calls != signedSessionMaxProviderRetries+1 {
|
||||
t.Fatalf("provider retry did not recover: calls=%d result=%+v", calls, result)
|
||||
}
|
||||
if len(waits) != signedSessionMaxProviderRetries {
|
||||
t.Fatalf("provider retry waits = %v", waits)
|
||||
}
|
||||
for _, delay := range waits {
|
||||
if delay != 10*time.Second {
|
||||
t.Fatalf("Retry-After header was not authoritative: waits=%v", waits)
|
||||
}
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-provider-retry" {
|
||||
t.Fatalf("provider retry changed the gateway session: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temporary provider retry budget is bounded", func(t *testing.T) {
|
||||
previousWait := signedSessionProviderWait
|
||||
signedSessionProviderWait = func(context.Context, time.Duration) error { return nil }
|
||||
t.Cleanup(func() { signedSessionProviderWait = previousWait })
|
||||
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: http.Header{"Retry-After": []string{"1"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_after_seconds":1}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "provider-unavailable-bounded", transport)
|
||||
config := SignedSessionConfig{Namespace: "provider-unavailable-bounded", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-bounded")
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("POST"),
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if calls != signedSessionMaxProviderRetries+1 {
|
||||
t.Fatalf("provider attempts = %d, want %d", calls, signedSessionMaxProviderRetries+1)
|
||||
}
|
||||
if result["code"] != "PROVIDER_UNAVAILABLE" || result["retryable"] != true {
|
||||
t.Fatalf("final provider failure was not returned intact: %+v", result)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-provider-bounded" {
|
||||
t.Fatalf("exhausted provider retries changed the gateway session: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaleSignedSession401RetriesWithoutClearingExchangedSession(t *testing.T) {
|
||||
oldRequestStarted := make(chan struct{})
|
||||
releaseOldRequest := make(chan struct{})
|
||||
@@ -950,8 +1446,10 @@ func TestStaleSignedSession401RetriesWithoutClearingExchangedSession(t *testing.
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{}`)),
|
||||
Request: req,
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway","action":"bootstrap_session"}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
|
||||
Reference in New Issue
Block a user