arblant
firefox-vpn-client / core / internal / proxy / relogin_test.go
relogin_test.go124 lines · 4.9 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
package proxy

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

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

// All credentials in this file are local fixtures; the control plane
// transport rejects every request it does not recognise.

// reloginFixture answers FxA and Guardian for one account "a": Guardian
// accepts only the access token of a new sign-in ("new-access"); a refresh
// returns another token Guardian refuses too. loginErr, when set, answers
// the sign-in instead.
func reloginFixture(t *testing.T, logins *atomic.Int32, loginErr func(*http.Request) (*http.Response, error)) {
	t.Helper()
	setCoreControlPlane(t, func(r *http.Request) (*http.Response, error) {
		if r.URL.Host == "api.accounts.firefox.com" {
			var body map[string]any
			if r.Body != nil {
				_ = json.NewDecoder(r.Body).Decode(&body)
			}
			switch {
			case r.URL.Path == "/v1/account/login":
				logins.Add(1)
				if loginErr != nil {
					return loginErr(r)
				}
				return coreResponse(r, http.StatusOK, `{"uid":"u1","sessionToken":"`+strings.Repeat("ab", 32)+`","verified":true}`, nil), nil
			case r.URL.Path == "/v1/session/destroy":
				return coreResponse(r, http.StatusOK, `{}`, nil), nil
			case r.URL.Path == "/v1/oauth/token" && body["grant_type"] == "refresh_token":
				return coreResponse(r, http.StatusOK, `{"access_token":"refreshed-access","refresh_token":"refresh-a","expires_in":3600,"token_type":"bearer"}`, nil), nil
			case r.URL.Path == "/v1/oauth/token":
				return coreResponse(r, http.StatusOK, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"token_type":"bearer"}`, nil), nil
			}
			return nil, errors.New("unexpected FxA request " + r.URL.Path)
		}
		if r.Header.Get("Authorization") == "Bearer new-access" {
			return coreResponse(r, http.StatusOK, `{"token":"`+accountJWT("a")+`"}`, quotaHeader(1)), nil
		}
		return coreResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`, nil), nil
	})
}

// Guardian refuses the account's tokens and a refresh does not help: the
// account signs in again with its stored password and continues.
func TestReloginWhenGuardianKeepsRefusingTokens(t *testing.T) {
	pool, accs := newTestAccountPool(t, "a")
	id := accs["a"].ID
	if err := pool.store.SetPassword(id, "secret"); err != nil {
		t.Fatal(err)
	}
	var logins atomic.Int32
	reloginFixture(t, &logins, nil)

	auth, pass, err := pool.AcquirePass(context.Background(), "https://guardian.invalid")
	if err != nil || pass == nil {
		t.Fatalf("expected a pass after the automatic sign-in: %v", err)
	}
	if auth.Token.AccessToken != "new-access" || logins.Load() != 1 {
		t.Fatalf("token %q after %d sign-ins", auth.Token.AccessToken, logins.Load())
	}
	if creds, _ := pool.store.Credentials(id); creds.RefreshToken != "new-refresh" {
		t.Fatalf("new tokens not stored: %+v", creds)
	}
	if a := accountStatus(t, pool, id); a.Status != accounts.StatusReady {
		t.Fatalf("status %s %q", a.Status, a.Reason)
	}
}

// A sign-in that cannot reach Mozilla does not ask the user to sign in: the
// account sits out briefly and the sign-in is tried again.
func TestReloginNetworkFailureIsRetried(t *testing.T) {
	pool, accs := newTestAccountPool(t, "a")
	id := accs["a"].ID
	if err := pool.store.SetPassword(id, "secret"); err != nil {
		t.Fatal(err)
	}
	var logins atomic.Int32
	reloginFixture(t, &logins, func(*http.Request) (*http.Response, error) {
		return nil, errors.New("connection reset")
	})

	if _, _, err := pool.AcquirePass(context.Background(), "https://guardian.invalid"); err == nil {
		t.Fatal("expected no pass while Mozilla is unreachable")
	}
	a := accountStatus(t, pool, id)
	if a.Status != accounts.StatusUnavailable || a.RetryAt.IsZero() || a.RetryAt.After(time.Now().Add(reloginNetworkRetry)) || !strings.Contains(a.Reason, "trying again") {
		t.Fatalf("status %s until %v: %q", a.Status, a.RetryAt, a.Reason)
	}
	// The next attempt is not held back by the 30-minute spacing.
	if _, err := pool.Relogin(context.Background(), a, errors.New("cause")); err == nil || logins.Load() < 2 {
		t.Fatalf("the sign-in must be tried again: %v logins=%d", err, logins.Load())
	}
}

// Mozilla refusing the stored password needs the user.
func TestReloginWrongPasswordNeedsSignIn(t *testing.T) {
	pool, accs := newTestAccountPool(t, "a")
	id := accs["a"].ID
	if err := pool.store.SetPassword(id, "old"); err != nil {
		t.Fatal(err)
	}
	var logins atomic.Int32
	reloginFixture(t, &logins, func(r *http.Request) (*http.Response, error) {
		return coreResponse(r, http.StatusBadRequest, `{"code":400,"errno":103,"message":"Incorrect password"}`, nil), nil
	})

	if _, _, err := pool.AcquirePass(context.Background(), "https://guardian.invalid"); err == nil {
		t.Fatal("expected no pass")
	}
	if a := accountStatus(t, pool, id); a.Status != accounts.StatusReauthRequired || !strings.Contains(a.Reason, "Incorrect password") {
		t.Fatalf("status %s %q", a.Status, a.Reason)
	}
}