arblant
firefox-vpn-client / core / internal / proxy / proxypass_test.go
proxypass_test.go113 lines · 4.3 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
package proxy

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/fxa"
	"github.com/armanlife/firefox-vpn-client/core/internal/guardian"
)

func TestIsQuotaExhausted(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name string
		pass *guardian.ProxyPassInfo
		want bool
	}{
		{"nil pass", nil, false},
		{"no quota headers", &guardian.ProxyPassInfo{}, false},
		{"zero remaining", &guardian.ProxyPassInfo{QuotaLeft: "0"}, true},
		{"quota remaining", &guardian.ProxyPassInfo{QuotaLeft: "1073741824"}, false},
		{"unparsable value", &guardian.ProxyPassInfo{QuotaLeft: "n/a"}, false},
	}
	for _, tc := range cases {
		if got := isQuotaExhausted(tc.pass); got != tc.want {
			t.Errorf("%s: expected %v, got %v", tc.name, tc.want, got)
		}
	}
}

func TestFetchProxyPassRetriesTransientGuardianFailure(t *testing.T) {
	var calls atomic.Int32
	pass := coreFixtureJWT(10 * time.Minute)
	setCoreControlPlane(t, func(r *http.Request) (*http.Response, error) {
		if !strings.HasSuffix(r.URL.Path, "/api/v1/fpn/token") {
			return nil, fmt.Errorf("unexpected request")
		}
		if calls.Add(1) < 3 {
			return coreResponse(r, http.StatusServiceUnavailable, "busy", nil), nil
		}
		return coreResponse(r, http.StatusOK, `{"token":"`+pass+`"}`, nil), nil
	})

	got, err := fetchProxyPassCtx(context.Background(), "https://guardian.invalid", "access")
	if err != nil || got.RawToken != pass || calls.Load() != 3 {
		t.Fatalf("expected success on the third attempt, got calls=%d err=%v", calls.Load(), err)
	}
}

func TestFetchProxyPassDoesNotRetryRegionRefusal(t *testing.T) {
	var calls atomic.Int32
	setCoreControlPlane(t, func(r *http.Request) (*http.Response, error) {
		calls.Add(1)
		return coreResponse(r, http.StatusUnavailableForLegalReasons, "region", nil), nil
	})

	_, err := fetchProxyPassCtx(context.Background(), "https://guardian.invalid", "access")
	if !errors.Is(err, guardian.ErrRegionUnavailable) || calls.Load() != 1 {
		t.Fatalf("expected one region refusal, got calls=%d err=%v", calls.Load(), err)
	}
	if GuardianFailureHint(err) == "" {
		t.Fatal("expected an operator hint for HTTP 451")
	}
}

func TestAcquireUsablePassActivatesOnNotEntitledWithoutRefresh(t *testing.T) {
	var activated atomic.Bool
	pass := coreFixtureJWT(10 * time.Minute)
	setCoreControlPlane(t, func(r *http.Request) (*http.Response, error) {
		switch {
		case strings.HasSuffix(r.URL.Path, "/api/v1/fpn/activate") && r.Method == http.MethodPost:
			activated.Store(true)
			return coreResponse(r, http.StatusOK, `{"subscribed":false,"uid":1,"maxBytes":"0"}`, nil), nil
		case strings.HasSuffix(r.URL.Path, "/api/v1/fpn/token") && !activated.Load():
			return coreResponse(r, http.StatusForbidden, "not entitled", nil), nil
		case strings.HasSuffix(r.URL.Path, "/api/v1/fpn/token"):
			return coreResponse(r, http.StatusOK, `{"token":"`+pass+`"}`, nil), nil
		}
		return nil, fmt.Errorf("unexpected request (an OAuth refresh is not expected) %s", r.URL)
	})

	auth := &runtimeAuth{Token: &fxa.TokenResponse{AccessToken: "access", RefreshToken: "refresh", ExpiresIn: 3600}, ObtainedAt: time.Now()}
	controller := &Controller{guardian: "https://guardian.invalid", auth: auth}
	gotAuth, got, err := controller.acquireUsablePass(context.Background())
	if err != nil || got.RawToken != pass || gotAuth != auth || !activated.Load() {
		t.Fatalf("expected activation then a pass with the same auth, got err=%v activated=%v", err, activated.Load())
	}
}

func TestAcquireUsablePassRefusesExhaustedQuota(t *testing.T) {
	pass := coreFixtureJWT(10 * time.Minute)
	setCoreControlPlane(t, func(r *http.Request) (*http.Response, error) {
		header := make(http.Header)
		header.Set("X-Quota-Limit", "100")
		header.Set("X-Quota-Remaining", "0")
		header.Set("X-Quota-Reset", "2026-11-01T00:00:00Z")
		return coreResponse(r, http.StatusOK, `{"token":"`+pass+`"}`, header), nil
	})

	auth := &runtimeAuth{Token: &fxa.TokenResponse{AccessToken: "access", ExpiresIn: 3600}, ObtainedAt: time.Now()}
	controller := &Controller{guardian: "https://guardian.invalid", auth: auth}
	_, _, err := controller.acquireUsablePass(context.Background())
	if !errors.Is(err, guardian.ErrQuotaExceeded) || !strings.Contains(err.Error(), "2026-11-01") {
		t.Fatalf("expected a quota error naming the reset time, got %v", err)
	}
}