arblant
firefox-vpn-client / core / internal / proxy / renewal_test.go
renewal_test.go152 lines · 4.5 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 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152
package proxy

import (
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

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

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

	controller := &Controller{done: make(chan struct{})}
	done := make(chan bool, 1)
	go func() {
		done <- controller.sleepOrDone(time.Hour)
	}()

	if err := controller.Close(); err != nil {
		t.Fatalf("Close returned error: %v", err)
	}

	select {
	case keptGoing := <-done:
		if keptGoing {
			t.Fatal("expected sleepOrDone to stop after Close")
		}
	case <-time.After(time.Second):
		t.Fatal("sleepOrDone did not wake after Close")
	}
}

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

	controller := &Controller{done: make(chan struct{})}
	controller.renewMu.Lock()
	defer controller.renewMu.Unlock()

	timedOutCh := make(chan bool, 1)
	go func() {
		_, timedOut := controller.renewWithTimeout(20 * time.Millisecond)
		timedOutCh <- timedOut
	}()

	select {
	case timedOut := <-timedOutCh:
		if !timedOut {
			t.Fatal("expected renewWithTimeout to time out")
		}
	case <-time.After(time.Second):
		t.Fatal("renewWithTimeout did not return")
	}
}

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

	controller := &Controller{done: make(chan struct{})}
	close(controller.done)

	_, timedOut := controller.renewWithTimeout(time.Hour)
	if timedOut {
		t.Fatal("expected closed controller not to report renewal timeout")
	}
}

func TestRenewalRetryDelayBacksOffTerminalFailures(t *testing.T) {
	now := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
	reset := now.Add(2 * time.Hour)
	tests := []struct {
		name string
		err  error
		want time.Duration
		code string
	}{
		{"transient", errors.New("connection reset"), proxyPassRetryDelay, "renewal_failed"},
		{"reauth", fmt.Errorf("refresh: %w", fxa.ErrReauthRequired), terminalRenewalRetryDelay, "reauth_required"},
		{"region", fmt.Errorf("x: %w", guardian.ErrRegionUnavailable), terminalRenewalRetryDelay, "region_unavailable"},
		{"quota with reset", &passQuotaError{usage: &guardian.ProxyUsage{Limit: 1, Reset: reset}}, 2*time.Hour + time.Minute, "quota_exhausted"},
	}
	for _, tc := range tests {
		if got := RenewalRetryDelay(tc.err, now); got != tc.want {
			t.Errorf("%s: delay %s, want %s", tc.name, got, tc.want)
		}
		if got := renewalFailureCode(tc.err); got != tc.code {
			t.Errorf("%s: code %q, want %q", tc.name, got, tc.code)
		}
	}
}

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

	controller := &Controller{
		current: &managedSession{session: &fakeProxySession{}, expiresAt: time.Now().Add(10 * time.Minute)},
	}
	failed := controller.current
	controller.passBackoff.record(time.Now(), errors.New("CONNECT 401"), errors.New("previous rebuild failed"))

	err := controller.rebuildSession(failed, &proxyConnectHTTPError{statusCode: 401, status: "401 Unauthorized"})
	if !errors.Is(err, errRebuildCoolingDown) || !strings.Contains(err.Error(), "previous rebuild failed") {
		t.Fatalf("expected a cooldown error without contacting Guardian, got %v", err)
	}
}

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

	controller := &Controller{
		current:       &managedSession{expiresAt: time.Now().Add(time.Minute)},
		lastRenewalAt: time.Now(),
	}
	if got := controller.nextRenewalDelay(); got < minRenewalInterval-time.Second {
		t.Fatalf("expected a renewal floor near %s, got %s", minRenewalInterval, got)
	}
}

// A replaced session is reported, so the daemon can test the connection;
// a failed or skipped rebuild is not.
func TestSessionRebuildIsReported(t *testing.T) {
	t.Parallel()

	rebuilt := make(chan struct{}, 2)
	controller := &Controller{
		current:          &managedSession{session: &fakeProxySession{}, expiresAt: time.Now().Add(10 * time.Minute)},
		refreshSession:   func() error { return nil },
		OnSessionRebuilt: func() { rebuilt <- struct{}{} },
	}
	if err := controller.rebuildSession(controller.current, errProxySessionUnhealthy); err != nil {
		t.Fatal(err)
	}
	select {
	case <-rebuilt:
	case <-time.After(2 * time.Second):
		t.Fatal("the rebuild was not reported")
	}

	controller.refreshSession = func() error { return errors.New("no pass") }
	if err := controller.rebuildSession(controller.current, errProxySessionUnhealthy); err == nil {
		t.Fatal("expected the failure")
	}
	select {
	case <-rebuilt:
		t.Fatal("a failed rebuild must not be reported")
	case <-time.After(50 * time.Millisecond):
	}
}