mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
102 lines
4.2 KiB
Go
102 lines
4.2 KiB
Go
package mailauth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// retrieveErr builds the error x/oauth2 returns from a token endpoint that
|
|
// answered with status and this RFC 6749 body.
|
|
func retrieveErr(status int, code, description string) *oauth2.RetrieveError {
|
|
return &oauth2.RetrieveError{
|
|
Response: &http.Response{StatusCode: status},
|
|
Body: []byte(fmt.Sprintf(`{"error":%q,"error_description":%q}`, code, description)),
|
|
ErrorCode: code,
|
|
ErrorDescription: description,
|
|
}
|
|
}
|
|
|
|
func TestClassifyTokenError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want TokenVerdict
|
|
}{
|
|
{"nil error", nil, TokenNotRefused},
|
|
{"plain transport failure", errors.New("dial tcp: i/o timeout"), TokenNotRefused},
|
|
{
|
|
// The shape the mail clients actually see: http.Client wraps
|
|
// whatever the oauth2 transport returned in a *url.Error.
|
|
"revoked refresh token through url.Error",
|
|
&url.Error{Op: "Get", URL: "https://graph.microsoft.com/v1.0/me/messages", Err: retrieveErr(400, "invalid_grant", "AADSTS50173: token revoked")},
|
|
TokenRevoked,
|
|
},
|
|
{"revoked refresh token", retrieveErr(400, "invalid_grant", "expired"), TokenRevoked},
|
|
{"consent withdrawn", retrieveErr(400, "consent_required", "admin consent revoked"), TokenRevoked},
|
|
{"policy now needs the user", retrieveErr(400, "interaction_required", "MFA required"), TokenRevoked},
|
|
{"session gone", retrieveErr(400, "login_required", "user must sign in"), TokenRevoked},
|
|
|
|
// The regression this classification exists to avoid: everything below
|
|
// is the provider having a moment, not a mailbox that needs its owner.
|
|
{"token endpoint throttling", retrieveErr(http.StatusTooManyRequests, "", ""), TokenRefusedNow},
|
|
{"token endpoint throttling with a code", retrieveErr(http.StatusTooManyRequests, "temporarily_unavailable", "slow down"), TokenRefusedNow},
|
|
{"token endpoint 500", retrieveErr(500, "", ""), TokenRefusedNow},
|
|
{"token endpoint 503", retrieveErr(503, "temporarily_unavailable", "try later"), TokenRefusedNow},
|
|
{"provider server_error", retrieveErr(400, "server_error", "transient"), TokenRefusedNow},
|
|
{"unrecognised code", retrieveErr(400, "something_new", "who knows"), TokenRefusedNow},
|
|
{"no code at all", retrieveErr(400, "", ""), TokenRefusedNow},
|
|
|
|
// An expired app secret answers this for every mailbox on the install
|
|
// at once, so it must never disconnect any of them.
|
|
{"our app credentials are wrong", retrieveErr(401, "invalid_client", "secret expired"), TokenRefusedNow},
|
|
{"our app is not allowed", retrieveErr(400, "unauthorized_client", "app blocked"), TokenRefusedNow},
|
|
|
|
// Some endpoints answer 200 with an error body; x/oauth2 still reports
|
|
// a RetrieveError, and the code is the only signal.
|
|
{"200 carrying an error code", retrieveErr(200, "invalid_grant", "revoked"), TokenRevoked},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ClassifyTokenError(tt.err).Verdict; got != tt.want {
|
|
t.Errorf("verdict = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A RetrieveError can arrive with no Response at all; reading the status off it
|
|
// must not panic.
|
|
func TestClassifyTokenErrorWithoutAResponse(t *testing.T) {
|
|
f := ClassifyTokenError(&oauth2.RetrieveError{ErrorCode: "invalid_grant"})
|
|
if !f.Revoked() {
|
|
t.Errorf("verdict = %v, want TokenRevoked", f.Verdict)
|
|
}
|
|
if f.Status != 0 {
|
|
t.Errorf("status = %d, want 0", f.Status)
|
|
}
|
|
}
|
|
|
|
// The provider's own words are what an operator needs to see next to a
|
|
// mailbox that was just disconnected.
|
|
func TestClassifyTokenErrorCarriesTheProvidersWords(t *testing.T) {
|
|
f := ClassifyTokenError(retrieveErr(400, "invalid_grant", "AADSTS50173: password changed"))
|
|
if !f.Refused() || !f.Revoked() {
|
|
t.Fatalf("refused=%v revoked=%v, want both true", f.Refused(), f.Revoked())
|
|
}
|
|
if f.ErrorCode != "invalid_grant" || f.Description != "AADSTS50173: password changed" || f.Status != 400 {
|
|
t.Errorf("got code=%q description=%q status=%d", f.ErrorCode, f.Description, f.Status)
|
|
}
|
|
}
|
|
|
|
func TestRefusedIsFalseForANonTokenFailure(t *testing.T) {
|
|
if ClassifyTokenError(errors.New("connection reset")).Refused() {
|
|
t.Error("a transport failure was reported as a token refusal")
|
|
}
|
|
}
|