Files
warmbly/internal/pkg/mailauth/mailauth_test.go
T

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")
}
}