337 lines
12 KiB
Go
337 lines
12 KiB
Go
package gcal_test
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"reflect"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||
|
|
)
|
||
|
|
|
||
|
|
// refusals are the ways Google refuses the stored grant: invalid_grant on
|
||
|
|
// the token refresh, and HTTP 401 from the Calendar API to the access token
|
||
|
|
// just refreshed.
|
||
|
|
var refusals = map[string]func(*gcaltest.Google){
|
||
|
|
"invalid_grant": (*gcaltest.Google).RevokeGrant,
|
||
|
|
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
|
||
|
|
}
|
||
|
|
|
||
|
|
// calls are the Calendar calls that use the stored grant.
|
||
|
|
var calls = map[string]func(*harness) error{
|
||
|
|
"list calendars": func(h *harness) error {
|
||
|
|
_, err := h.svc.Calendars(context.Background())
|
||
|
|
return err
|
||
|
|
},
|
||
|
|
"choose a calendar": func(h *harness) error {
|
||
|
|
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
|
||
|
|
return err
|
||
|
|
},
|
||
|
|
"push events": func(h *harness) error {
|
||
|
|
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
|
||
|
|
return errors.New("record ran after a refused grant")
|
||
|
|
})
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRefusedGrantRequiresReconnect refuses the grant on every Calendar
|
||
|
|
// call: the call fails with ErrReconnect, the refresh token is cleared,
|
||
|
|
// last_sync_error says why, the chosen calendar is kept and Status reports
|
||
|
|
// reconnect required.
|
||
|
|
func TestRefusedGrantRequiresReconnect(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
for refusal, refuse := range refusals {
|
||
|
|
for call, do := range calls {
|
||
|
|
t.Run(refusal+"/"+call, func(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := chosen(t)
|
||
|
|
refuse(h.google)
|
||
|
|
h.clock = h.clock.Add(time.Hour)
|
||
|
|
err := do(h)
|
||
|
|
if !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Fatalf("%s = %v, want ErrReconnect", call, err)
|
||
|
|
}
|
||
|
|
assertNoSecret(t, "error", err.Error())
|
||
|
|
row, ok := h.row(t)
|
||
|
|
switch {
|
||
|
|
case !ok:
|
||
|
|
t.Fatal("a refused grant forgot the connection")
|
||
|
|
case row.RefreshTokenCt != nil:
|
||
|
|
t.Error("refresh token kept after Google refused it")
|
||
|
|
case row.LastSyncError.String != "Google access was revoked or expired — reconnect":
|
||
|
|
t.Errorf("last_sync_error = %v", row.LastSyncError)
|
||
|
|
case row.CalendarID.String != dinners.ID || row.CalendarName.String != dinners.Summary:
|
||
|
|
t.Errorf("calendar = %v %v, want %s kept", row.CalendarID, row.CalendarName, dinners.ID)
|
||
|
|
case row.UpdatedAt != "2026-10-07T19:30:00.000Z":
|
||
|
|
t.Errorf("updated_at = %s, want the refusal's time", row.UpdatedAt)
|
||
|
|
}
|
||
|
|
st, err := h.svc.Status(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if st.State != gcal.StatusReconnectRequired || st.LastSyncError == nil || *st.LastSyncError != gcal.ReconnectReason {
|
||
|
|
t.Errorf("Status = %+v, want reconnect_required with the reason", st)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The cleared grant is never offered to Google again, by
|
||
|
|
// any call.
|
||
|
|
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
|
||
|
|
for again, do := range calls {
|
||
|
|
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
|
||
|
|
t.Error("a call reached Google after the grant was refused")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRefusedGrantBeforeACalendarIsChosen refuses the grant while no
|
||
|
|
// calendar is chosen, on each call that can record it then (listing and
|
||
|
|
// choosing): every Calendar call, push included, is ErrReconnect rather than
|
||
|
|
// ErrNoCalendar, since only connecting again restores the grant. After
|
||
|
|
// connecting again and choosing a calendar, a push succeeds.
|
||
|
|
func TestRefusedGrantBeforeACalendarIsChosen(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
for refusal, refuse := range refusals {
|
||
|
|
for _, call := range []string{"list calendars", "choose a calendar"} {
|
||
|
|
t.Run(refusal+"/"+call, func(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := connected(t)
|
||
|
|
refuse(h.google)
|
||
|
|
if err := calls[call](h); !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Fatalf("%s = %v, want ErrReconnect", call, err)
|
||
|
|
}
|
||
|
|
if row, _ := h.row(t); row.RefreshTokenCt != nil || row.CalendarID.Valid {
|
||
|
|
t.Fatalf("row = %+v, want the grant cleared and no calendar chosen", row)
|
||
|
|
}
|
||
|
|
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
|
||
|
|
for again, do := range calls {
|
||
|
|
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
|
||
|
|
t.Error("a call reached Google after the grant was refused")
|
||
|
|
}
|
||
|
|
|
||
|
|
h.google.AcceptAccessToken()
|
||
|
|
c := h.connect(t, session)
|
||
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||
|
|
t.Fatalf("Complete: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
|
||
|
|
t.Fatalf("SelectCalendar after connecting again: %v", err)
|
||
|
|
}
|
||
|
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
|
||
|
|
t.Errorf("PushEvents after connecting again: %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestReconnectAfterRefusal connects again after Google refused the grant:
|
||
|
|
// the new token is stored, the reason cleared and the calendar kept.
|
||
|
|
func TestReconnectAfterRefusal(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := chosen(t)
|
||
|
|
h.google.RevokeGrant()
|
||
|
|
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||
|
|
}
|
||
|
|
c := h.connect(t, session)
|
||
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||
|
|
t.Fatalf("Complete: %v", err)
|
||
|
|
}
|
||
|
|
st, err := h.svc.Status(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if st.State != gcal.StatusConnected || st.LastSyncError != nil || st.CalendarID == nil || *st.CalendarID != dinners.ID {
|
||
|
|
t.Errorf("Status = %+v, want connected to %s with no error", st, dinners.ID)
|
||
|
|
}
|
||
|
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
|
||
|
|
t.Errorf("PushEvents after connecting again: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestDisconnectAfterRefusal disconnects a connection whose grant Google
|
||
|
|
// refused: there is nothing to revoke, and the connection is deleted.
|
||
|
|
func TestDisconnectAfterRefusal(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := chosen(t)
|
||
|
|
h.google.RevokeGrant()
|
||
|
|
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||
|
|
}
|
||
|
|
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||
|
|
t.Fatalf("Disconnect: %v", err)
|
||
|
|
}
|
||
|
|
if n := len(h.google.Revoked()); n != 0 {
|
||
|
|
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
|
||
|
|
}
|
||
|
|
if _, ok := h.row(t); ok {
|
||
|
|
t.Error("connection kept after disconnecting")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// transients are the failures Google asks clients to retry.
|
||
|
|
var transients = []int{http.StatusServiceUnavailable, http.StatusInternalServerError, http.StatusTooManyRequests}
|
||
|
|
|
||
|
|
// TestTransientFailureIsRetried fails each Google request three times in a
|
||
|
|
// row: it is tried three times (two retries), then fails as retryable, and
|
||
|
|
// nothing is disconnected or cleared.
|
||
|
|
func TestTransientFailureIsRetried(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
for _, status := range transients {
|
||
|
|
for name, tc := range map[string]struct {
|
||
|
|
fail func(*gcaltest.Google)
|
||
|
|
do func(*harness) error
|
||
|
|
tries func(*gcaltest.Google) int
|
||
|
|
}{
|
||
|
|
"token refresh": {
|
||
|
|
fail: func(g *gcaltest.Google) { g.FailToken(status) },
|
||
|
|
do: calls["list calendars"],
|
||
|
|
tries: (*gcaltest.Google).TokenCalls,
|
||
|
|
},
|
||
|
|
"calendar list": {
|
||
|
|
fail: func(g *gcaltest.Google) { g.FailCalendarList(status) },
|
||
|
|
do: calls["choose a calendar"],
|
||
|
|
tries: (*gcaltest.Google).CalendarCalls,
|
||
|
|
},
|
||
|
|
"event write": {
|
||
|
|
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 3, status) },
|
||
|
|
do: calls["push events"],
|
||
|
|
tries: (*gcaltest.Google).EventRequests,
|
||
|
|
},
|
||
|
|
} {
|
||
|
|
t.Run(name+"/"+http.StatusText(status), func(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := chosen(t)
|
||
|
|
before, _ := h.row(t)
|
||
|
|
tried := tc.tries(h.google)
|
||
|
|
tc.fail(h.google)
|
||
|
|
err := tc.do(h)
|
||
|
|
var calendar *gcal.CalendarError
|
||
|
|
if !errors.As(err, &calendar) || !calendar.Retryable() {
|
||
|
|
t.Fatalf("err = %v, want a retryable *CalendarError", err)
|
||
|
|
}
|
||
|
|
assertNoSecret(t, "error", err.Error())
|
||
|
|
if n := tc.tries(h.google) - tried; n != 3 {
|
||
|
|
t.Errorf("tried %d times, want 3", n)
|
||
|
|
}
|
||
|
|
after, ok := h.row(t)
|
||
|
|
if !ok || !reflect.DeepEqual(before, after) {
|
||
|
|
t.Errorf("connection = %+v, %v; want it unchanged %+v", after, ok, before)
|
||
|
|
}
|
||
|
|
if st, err := h.svc.Status(context.Background()); err != nil || st.State != gcal.StatusConnected {
|
||
|
|
t.Errorf("Status = %+v, %v; want connected", st, err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTransientFailureRecovers fails each Google request twice: the second
|
||
|
|
// retry succeeds.
|
||
|
|
func TestTransientFailureRecovers(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
for name, tc := range map[string]struct {
|
||
|
|
fail func(*gcaltest.Google)
|
||
|
|
do func(*harness) error
|
||
|
|
}{
|
||
|
|
"calendar list": {
|
||
|
|
fail: func(g *gcaltest.Google) { g.FailCalendarListNext(2, http.StatusServiceUnavailable) },
|
||
|
|
do: calls["choose a calendar"],
|
||
|
|
},
|
||
|
|
"event write": {
|
||
|
|
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 2, http.StatusServiceUnavailable) },
|
||
|
|
do: func(h *harness) error {
|
||
|
|
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
|
||
|
|
},
|
||
|
|
},
|
||
|
|
} {
|
||
|
|
t.Run(name, func(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := chosen(t)
|
||
|
|
tc.fail(h.google)
|
||
|
|
if err := tc.do(h); err != nil {
|
||
|
|
t.Fatalf("err = %v, want the second retry to succeed", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTransientFailureBacksOff checks the waits between tries: the first
|
||
|
|
// retry waits the retry delay, the second twice that.
|
||
|
|
func TestTransientFailureBacksOff(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := chosen(t)
|
||
|
|
gcal.SetRetryDelay(h.svc, 50*time.Millisecond)
|
||
|
|
h.google.FailCalendarList(http.StatusServiceUnavailable)
|
||
|
|
start := time.Now()
|
||
|
|
if _, err := h.svc.Calendars(context.Background()); err == nil {
|
||
|
|
t.Fatal("Calendars succeeded")
|
||
|
|
}
|
||
|
|
if took := time.Since(start); took < 150*time.Millisecond {
|
||
|
|
t.Errorf("three tries took %s, want at least 150ms of waits", took)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStatus(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
h := newHarness(t)
|
||
|
|
st, err := h.svc.Status(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got := statusJSON(t, st); got != `{"status":"not_connected","calendar_id":null,"calendar_name":null,"connected_at":null,"last_sync_error":null}` {
|
||
|
|
t.Errorf("not connected: Status = %s", got)
|
||
|
|
}
|
||
|
|
c := h.connect(t, session)
|
||
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
st, err = h.svc.Status(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got := statusJSON(t, st); got != `{"status":"connected","calendar_id":null,"calendar_name":null,"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":null}` {
|
||
|
|
t.Errorf("connected: Status = %s", got)
|
||
|
|
}
|
||
|
|
h.google.SetCalendars(0, dinners)
|
||
|
|
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
h.google.RevokeGrant()
|
||
|
|
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
|
||
|
|
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||
|
|
}
|
||
|
|
st, err = h.svc.Status(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
want := `{"status":"reconnect_required","calendar_id":"dinners@group.calendar.google.com","calendar_name":"Dinners",` +
|
||
|
|
`"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":"Google access was revoked or expired — reconnect"}`
|
||
|
|
if got := statusJSON(t, st); got != want {
|
||
|
|
t.Errorf("reconnect required: Status =\n%s\nwant\n%s", got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func statusJSON(t *testing.T, st gcal.Status) string {
|
||
|
|
t.Helper()
|
||
|
|
b, err := json.Marshal(st)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return string(b)
|
||
|
|
}
|