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
package fxa
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/armanlife/firefox-vpn-client/core/internal/controlplane"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func useFxaFixture(t *testing.T, handler func(r *http.Request, body map[string]any) (int, string)) {
t.Helper()
previous := controlplane.Transport()
t.Cleanup(func() { controlplane.SetTransport(previous) })
controlplane.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
var body map[string]any
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
status, text := handler(r, body)
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(text)), Request: r}, nil
}))
}
func TestErrnoAndMessageReadFxaErrors(t *testing.T) {
err := error(newFxaAPIError(400, []byte(`{"errno":125,"message":"Request blocked"}`)))
if Errno(err) != ErrnoRequestBlocked || Message(err) != "Request blocked" {
t.Fatalf("unexpected errno/message: %d %q", Errno(err), Message(err))
}
if Errno(errors.New("plain")) != 0 || Message(errors.New("plain")) != "plain" {
t.Fatal("plain errors must fall back to their text")
}
}
func TestLoginWithUnblockCodeSendsCode(t *testing.T) {
useFxaFixture(t, func(r *http.Request, body map[string]any) (int, string) {
if r.URL.Path != "/v1/account/login" || body["unblockCode"] != "ABC123" || body["email"] != "user@example.com" {
return 500, "unexpected request"
}
return 200, `{"sessionToken":"00","uid":"u","verified":true}`
})
resp, err := LoginWithUnblockCode(context.Background(), "user@example.com", "secret", " abc123 ")
if err != nil || !resp.Verified {
t.Fatalf("expected a verified login, got %+v %v", resp, err)
}
}
// An email typed in another letter case than the account's: FxA refuses it
// with the account's own spelling (errno 120), and the sign-in is repeated
// once with that spelling, its own password hash, and the typed email as
// originalLoginEmail, as Firefox does.
func TestLoginCorrectsTheEmailCase(t *testing.T) {
const stored, typed = "Asra@example.com", "asra@example.com"
wantAuthPW, err := deriveAuthPW(stored, "secret")
if err != nil {
t.Fatal(err)
}
var emails []any
useFxaFixture(t, func(r *http.Request, body map[string]any) (int, string) {
emails = append(emails, body["email"])
if body["email"] != stored {
return 400, `{"code":400,"errno":120,"message":"Incorrect email case","email":"` + stored + `"}`
}
if body["originalLoginEmail"] != typed || body["authPW"] != hex.EncodeToString(wantAuthPW) {
return 500, "unexpected request"
}
return 200, `{"sessionToken":"00","uid":"u","verified":true}`
})
resp, err := Login(context.Background(), typed, "secret")
if err != nil || resp.Email != stored || SignedInEmail(typed, resp, err) != stored {
t.Fatalf("login %+v %v; want it signed in as %s", resp, err, stored)
}
if len(emails) != 2 {
t.Fatalf("emails sent %v; want the typed one, then the account's", emails)
}
// The corrected sign-in is blocked: the error keeps the account's
// spelling, for the unblock code and the next attempt.
useFxaFixture(t, func(r *http.Request, body map[string]any) (int, string) {
if body["email"] != stored {
return 400, `{"errno":120,"email":"` + stored + `"}`
}
return 400, `{"errno":125,"message":"Request blocked"}`
})
resp, err = Login(context.Background(), typed, "secret")
if Errno(err) != ErrnoRequestBlocked || SignedInEmail(typed, resp, err) != stored {
t.Fatalf("blocked login: %v, email %q", err, SignedInEmail(typed, resp, err))
}
// A wrong password in the right case is an ordinary failure, not
// repeated.
calls := 0
useFxaFixture(t, func(r *http.Request, body map[string]any) (int, string) {
calls++
return 400, `{"errno":103,"message":"Incorrect password","email":"` + stored + `"}`
})
_, err = Login(context.Background(), stored, "wrong")
if Errno(err) != 103 || calls != 1 || SignedInEmail(stored, nil, err) != stored {
t.Fatalf("wrong password: %v after %d calls", err, calls)
}
}
func TestVerifyTOTPReportsRejectedCode(t *testing.T) {
accept := false
useFxaFixture(t, func(r *http.Request, body map[string]any) (int, string) {
if r.URL.Path != "/v1/session/verify/totp" || !strings.HasPrefix(r.Header.Get("Authorization"), "Hawk ") || body["code"] != "123456" {
return 500, "unexpected request"
}
if accept {
return 200, `{"success":true}`
}
return 200, `{"success":false}`
})
session := strings.Repeat("ab", 32)
if err := VerifyTOTP(context.Background(), session, "123456"); err == nil {
t.Fatal("expected a rejected code to fail")
}
accept = true
if err := VerifyTOTP(context.Background(), session, " 123456 "); err != nil {
t.Fatalf("expected success, got %v", err)
}
}
func TestAnswerChallengeAsksCaptchaSolver(t *testing.T) {
ch := fastlyChallenge{Ty: "captcha", Data: json.RawMessage(`{"image_b64":"data:image/png;base64,AAAA"}`)}
if _, err := answerChallenge(context.Background(), nil, "", "", ch); err == nil {
t.Fatal("expected an error without a CAPTCHA solver")
}
var shown string
ctx := WithCaptchaSolver(context.Background(), func(_ context.Context, image string) (string, error) {
shown = image
return " x7Kp2 ", nil
})
answer, err := answerChallenge(ctx, nil, "", "", ch)
if err != nil {
t.Fatal(err)
}
got := answer.(map[string]interface{})
if got["ty"] != "captcha" || got["answer"] != "x7Kp2" || shown != "data:image/png;base64,AAAA" {
t.Fatalf("unexpected CAPTCHA answer %v (shown %q)", got, shown)
}
}