mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
176 lines
6.8 KiB
Go
176 lines
6.8 KiB
Go
package msgraph
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// escapeRT stands in for the network behind the oauth2 transport. A token
|
|
// refresh that fails must short-circuit before Graph is dialled, so any call
|
|
// reaching here is itself a failure.
|
|
type escapeRT struct{ t *testing.T }
|
|
|
|
func (e escapeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
e.t.Errorf("request escaped to %s: a failed token refresh must not reach the provider", req.URL)
|
|
return nil, errors.New("unexpected network call")
|
|
}
|
|
|
|
// tokenRefusal boots a real client whose token endpoint answers every refresh
|
|
// with status and body. The mailbox's access token is already expired, so the
|
|
// first API call refreshes, is refused, and returns through the same path
|
|
// production takes: oauth2.Transport -> http.Client -> *url.Error.
|
|
func tokenRefusal(t *testing.T, status int, body string) (*Client, *int32) {
|
|
t.Helper()
|
|
|
|
var calls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
return newRefusingClient(t, srv.URL), &calls
|
|
}
|
|
|
|
func newRefusingClient(t *testing.T, tokenURL string) *Client {
|
|
t.Helper()
|
|
|
|
c := &Client{Email: "sender@outlook.com"}
|
|
cfg := oauth2.Config{
|
|
ClientID: "client-id",
|
|
ClientSecret: "client-secret",
|
|
// Pinned so the refusal is one request, not the auth-style probe's two.
|
|
Endpoint: oauth2.Endpoint{TokenURL: tokenURL, AuthStyle: oauth2.AuthStyleInParams},
|
|
}
|
|
expired := &oauth2.Token{
|
|
AccessToken: "stale",
|
|
RefreshToken: "refresh-token",
|
|
Expiry: time.Now().Add(-time.Hour),
|
|
}
|
|
if merr := c.Init(context.Background(), expired, cfg); merr != nil {
|
|
t.Fatalf("Init: %v", merr.Message)
|
|
}
|
|
tr, ok := c.hc.Transport.(*oauth2.Transport)
|
|
if !ok {
|
|
t.Fatalf("Init did not build an oauth2 transport, got %T", c.hc.Transport)
|
|
}
|
|
tr.Base = escapeRT{t}
|
|
return c
|
|
}
|
|
|
|
func mailErrorOf(t *testing.T, err error) *errx.MailError {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("call succeeded, want a mail error")
|
|
}
|
|
var mailErr *errx.MailError
|
|
if !errors.As(err, &mailErr) {
|
|
t.Fatalf("error %v (%T) is not a *errx.MailError, so the sync loop cannot classify it", err, err)
|
|
}
|
|
return mailErr
|
|
}
|
|
|
|
const invalidGrantBody = `{"error":"invalid_grant","error_description":"AADSTS50173: The provided grant has expired due to it being revoked."}`
|
|
|
|
// The incident: a revoked grant reported as an unreachable server, with a
|
|
// promised retry that could never succeed. Every call shape has to agree,
|
|
// because the mailbox reaches the same dead grant through all of them.
|
|
func TestRevokedGrantIsAnAuthenticationErrorOnEveryCallPath(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("fetch message", func(t *testing.T) {
|
|
c, calls := tokenRefusal(t, http.StatusBadRequest, invalidGrantBody)
|
|
_, err := c.FetchMessage(ctx, FolderInbox, "message-id")
|
|
if got := mailErrorOf(t, err).Code; got != errx.MailErrorCodeAuthenticationFailed {
|
|
t.Errorf("code = %s, want %s", got, errx.MailErrorCodeAuthenticationFailed)
|
|
}
|
|
if atomic.LoadInt32(calls) == 0 {
|
|
t.Error("the token endpoint was never called, so no refresh was exercised")
|
|
}
|
|
})
|
|
|
|
t.Run("list messages", func(t *testing.T) {
|
|
c, _ := tokenRefusal(t, http.StatusBadRequest, invalidGrantBody)
|
|
_, _, err := c.ListMessagesSince(ctx, FolderInbox, time.Now().Add(-24*time.Hour), "", 10)
|
|
if got := mailErrorOf(t, err).Code; got != errx.MailErrorCodeAuthenticationFailed {
|
|
t.Errorf("code = %s, want %s", got, errx.MailErrorCodeAuthenticationFailed)
|
|
}
|
|
})
|
|
|
|
t.Run("send", func(t *testing.T) {
|
|
c, _ := tokenRefusal(t, http.StatusBadRequest, invalidGrantBody)
|
|
_, err := c.SendMessage(ctx, []string{"partner@example.com"}, nil, nil,
|
|
"<minted@outlook.com>", "subject", "body", "", nil, nil, nil)
|
|
if got := mailErrorOf(t, err).Code; got != errx.MailErrorCodeAuthenticationFailed {
|
|
t.Errorf("code = %s, want %s", got, errx.MailErrorCodeAuthenticationFailed)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A revoked grant must ask the customer to reconnect rather than promise a
|
|
// retry, which is the difference between the two resolve methods.
|
|
func TestRevokedGrantAsksForReauthentication(t *testing.T) {
|
|
c, _ := tokenRefusal(t, http.StatusBadRequest, invalidGrantBody)
|
|
_, err := c.FetchMessage(context.Background(), FolderInbox, "message-id")
|
|
mailErr := mailErrorOf(t, err)
|
|
if mailErr.ResolveMethod != errx.MailErrorResolveMethodAuth {
|
|
t.Errorf("resolve method = %s, want %s", mailErr.ResolveMethod, errx.MailErrorResolveMethodAuth)
|
|
}
|
|
}
|
|
|
|
// The other half of the classification: a provider having a moment must not
|
|
// cost a customer their mailbox. Each of these reaches the consumer as a
|
|
// server error, which retries, instead of an auth error, which deactivates.
|
|
func TestTransientTokenRefusalsStayRetryable(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
}{
|
|
{"throttled", http.StatusTooManyRequests, `{"error":"temporarily_unavailable"}`},
|
|
{"token endpoint 500", http.StatusInternalServerError, `{}`},
|
|
{"token endpoint 503", http.StatusServiceUnavailable, `{"error":"temporarily_unavailable"}`},
|
|
{"provider server_error", http.StatusBadRequest, `{"error":"server_error","error_description":"try again"}`},
|
|
{"unrecognised refusal", http.StatusBadRequest, `{"error":"something_new"}`},
|
|
// An expired app secret answers this for every Outlook mailbox on the
|
|
// install at once; deactivating them all would be the worse outage.
|
|
{"our app credentials expired", http.StatusUnauthorized, `{"error":"invalid_client","error_description":"secret expired"}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c, _ := tokenRefusal(t, tc.status, tc.body)
|
|
_, err := c.FetchMessage(context.Background(), FolderInbox, "message-id")
|
|
mailErr := mailErrorOf(t, err)
|
|
if mailErr.Code != errx.MailErrorCodeServerUnreachable {
|
|
t.Fatalf("code = %s, want %s: this deactivates the mailbox", mailErr.Code, errx.MailErrorCodeServerUnreachable)
|
|
}
|
|
if mailErr.ResolveMethod != errx.MailErrorResolveMethodRetry {
|
|
t.Errorf("resolve method = %s, want %s", mailErr.ResolveMethod, errx.MailErrorResolveMethodRetry)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A token endpoint that cannot be reached at all is the case the old code was
|
|
// written for, and it still has to read as a transport failure. Port 1 is
|
|
// refused immediately and needs no DNS, so this stays hermetic and fast.
|
|
func TestUnreachableTokenEndpointIsAServerError(t *testing.T) {
|
|
c := newRefusingClient(t, "http://127.0.0.1:1/token")
|
|
_, err := c.FetchMessage(context.Background(), FolderInbox, "message-id")
|
|
if got := mailErrorOf(t, err).Code; got != errx.MailErrorCodeServerUnreachable {
|
|
t.Errorf("code = %s, want %s", got, errx.MailErrorCodeServerUnreachable)
|
|
}
|
|
}
|