arblant
firefox-vpn-client / core / internal / proxy / failure_test.go
failure_test.go111 lines · 4.6 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
package proxy

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"testing"
	"time"

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

func TestFailureCode(t *testing.T) {
	dial := func(err error) error {
		return &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{Syscall: "connect", Err: err}}
	}
	refused := dial(errors.New("refused"))
	if c, err := net.DialTimeout("tcp", "127.0.0.1:1", time.Second); err == nil {
		_ = c.Close()
	} else {
		refused = err // the real "connection refused" of this system
	}
	cases := []struct {
		name string
		err  error
		want string
	}{
		{"nil", nil, ""},
		{"reauth", fmt.Errorf("renewing: %w", fxa.ErrReauthRequired), FailureSignIn},
		{"relogin", &ReloginError{Reason: "confirm", err: fxa.ErrReauthRequired}, FailureSignIn},
		{"quota", &passQuotaError{}, FailureQuota},
		{"quota 429", &guardian.HTTPError{Operation: "pass", StatusCode: 429}, FailureQuota},
		{"not entitled", &guardian.HTTPError{Operation: "pass", StatusCode: 403}, FailureNotEntitled},
		{"region", &guardian.HTTPError{Operation: "pass", StatusCode: 451}, FailureRegion},
		{"guardian 502", &guardian.HTTPError{Operation: "pass", StatusCode: 502}, FailureServer},
		{"no account", &accounts.NoUsableAccountError{Summary: "1 used up"}, FailureNoAccount},
		{"connect 403", &proxyConnectHTTPError{statusCode: 403, status: "403 Forbidden"}, FailureRefused},
		{"chosen location refused", fmt.Errorf("tunnel: %w", &LocationRefusedError{Err: &proxyConnectHTTPError{statusCode: 403, status: "403 Forbidden"}}), FailureLocationRefused},
		{"rebuild cooling after a refusal", fmt.Errorf("%w for 9s: %w", errRebuildCoolingDown, &proxyConnectHTTPError{statusCode: 401, status: "401"}), FailureRefused},
		{"rebuild cooling after a timeout", fmt.Errorf("%w for 9s: %w", errRebuildCoolingDown, &tunnelOpenTimeoutError{timeout: time.Second}), FailureUnreachable},
		{"dns", fmt.Errorf("pass: %w", &net.DNSError{Err: "no such host", Name: "vpn.mozilla.org", IsNotFound: true}), FailureNoNetwork},
		{"refused", refused, FailureUnreachable},
		{"tunnel timeout", &tunnelOpenTimeoutError{timeout: time.Second}, FailureUnreachable},
		{"deadline", context.DeadlineExceeded, FailureUnreachable},
		{"eof", fmt.Errorf("tls handshake: %w", io.EOF), FailureUnreachable},
		{"other", errors.New("something new"), FailureOther},
	}
	for _, tc := range cases {
		if got := FailureCode(tc.err); got != tc.want {
			t.Errorf("%s: FailureCode(%v) = %q, want %q", tc.name, tc.err, got, tc.want)
		}
	}
	for _, e := range noNetworkErrnos {
		if got := FailureCode(dial(e)); got != FailureNoNetwork {
			t.Errorf("errno %d: got %q, want %q", e, got, FailureNoNetwork)
		}
	}
}

// A rebuild that failed for want of a network does not space the next one;
// RetryNow forgets the spacing.
func TestRebuildBackoffAndTheNetwork(t *testing.T) {
	noRoute := &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{Syscall: "connect", Err: noNetworkErrnos[0]}}
	now := time.Now()
	var b rebuildBackoff
	b.record(now, nil, noRoute)
	if w := b.wait(newPassSteps, now); w > 0 {
		t.Fatalf("a failure without a network waits %s", w)
	}
	b.record(now, nil, context.DeadlineExceeded)
	if w := b.wait(newPassSteps, now); w <= 0 {
		t.Fatal("a failure at Mozilla must space the next rebuild")
	}
	c := &Controller{}
	c.passBackoff.record(now, nil, context.DeadlineExceeded)
	c.lastRenewalErr, c.nextAttemptAt = noRoute, now.Add(time.Hour)
	c.RetryNow()
	if w := c.passBackoff.wait(newPassSteps, now); w > 0 || !c.nextAttemptAt.IsZero() {
		t.Fatalf("after RetryNow: wait %s, next attempt %s", w, c.nextAttemptAt)
	}
}

// While the phone reports no network, a tunnel failure rebuilds nothing:
// it fails at once as no network, asking Guardian nothing.
func TestNoRebuildWithoutANetwork(t *testing.T) {
	netstate.Report(false)
	t.Cleanup(func() { netstate.Report(true) })
	c := &Controller{}
	err := c.rebuildSession(nil, errors.New("tunnel failed"))
	if !errors.Is(err, ErrNoNetwork) || FailureCode(err) != FailureNoNetwork {
		t.Fatalf("got %v (%s)", err, FailureCode(err))
	}
	if !c.passBackoff.lastAt.IsZero() || !c.redialBackoff.lastAt.IsZero() {
		t.Fatal("a rebuild was attempted without a network")
	}
}

func TestSessionReplaced(t *testing.T) {
	if !SessionReplaced(`Get "https://www.google.com/generate_204": http2: client connection force closed via ClientConn.Close`) {
		t.Fatal("a closed HTTP/2 session not recognized")
	}
	if SessionReplaced("dial tcp: i/o timeout") {
		t.Fatal("a timeout taken for a replaced session")
	}
}