Handle Google token failures and report Google Calendar status
When Google refuses the stored grant (invalid_grant on refresh, or HTTP 401 from the Calendar API to the access token just refreshed) on any Calendar call - listing, choosing or pushing - the connection becomes reconnect required under the account lock: the refresh token is cleared and last_sync_error set to "Google access was revoked or expired — reconnect". The call answers 409 google_reconnect_required, and later calls answer the same without contacting Google until a reconnect or a disconnect. The chosen calendar and stored calendar event ids are kept: the pushed events are still on that calendar, so forgetEvents is not run. HTTP 429 and 5xx from the token endpoint or the Calendar API are retried at most twice with backoff, then answered 503 google_unavailable, retryable, without clearing or disconnecting anything. Add GET /api/v1/integrations/google, reporting not_connected, connected or reconnect_required with the chosen calendar and last_sync_error. Migration 0015 rebuilds integrations_google so refresh_token_ct can be cleared, with a CHECK that a row without a token carries the reason. Maestro task b8d8da5a-0450-4571-963e-b3dfead24283. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ type Querier interface {
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
// Clears every day's calendar event id: none is on the chosen calendar.
|
||||
ClearCalendarEvents(ctx context.Context, updatedAt string) error
|
||||
// Records that Google refuses the stored grant: clears the refresh token and
|
||||
// sets last_sync_error, keeping the chosen calendar. Affects no row when
|
||||
// nothing is connected.
|
||||
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
|
||||
// Drops references to the runs created before the cutoff, so the purge
|
||||
// can delete them.
|
||||
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
|
||||
@@ -10,6 +10,28 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const clearIntegrationsGoogleToken = `-- name: ClearIntegrationsGoogleToken :execrows
|
||||
UPDATE integrations_google
|
||||
SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ?
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
type ClearIntegrationsGoogleTokenParams struct {
|
||||
LastSyncError sql.NullString
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Records that Google refuses the stored grant: clears the refresh token and
|
||||
// sets last_sync_error, keeping the chosen calendar. Affects no row when
|
||||
// nothing is connected.
|
||||
func (q *Queries) ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, clearIntegrationsGoogleToken, arg.LastSyncError, arg.UpdatedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec
|
||||
DELETE FROM integrations_google
|
||||
WHERE id = 1
|
||||
|
||||
+54
-30
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -55,16 +54,28 @@ type Selection struct {
|
||||
// response body.
|
||||
type CalendarError struct {
|
||||
msg string
|
||||
// retryable is set when Google answered every try with a transient
|
||||
// failure (HTTP 429 or 5xx).
|
||||
retryable bool
|
||||
}
|
||||
|
||||
func (e *CalendarError) Error() string { return e.msg }
|
||||
|
||||
// Retryable reports whether Google was only briefly unable to answer, so
|
||||
// trying again later may succeed. Nothing was disconnected.
|
||||
func (e *CalendarError) Retryable() bool { return e.retryable }
|
||||
|
||||
// Calendars lists the connected account's calendars it can push to: those
|
||||
// it is a writer or owner of, in Google's order.
|
||||
// it is a writer or owner of, in Google's order. A grant Google refuses is
|
||||
// ErrReconnect, recorded as reconnect required (recordRefusal).
|
||||
func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
return s.calendars(ctx)
|
||||
cals, err := s.calendars(ctx)
|
||||
if err != nil {
|
||||
return nil, s.recordRefusal(ctx, err)
|
||||
}
|
||||
return cals, nil
|
||||
}
|
||||
|
||||
// SelectCalendar makes id the calendar MealQuest pushes to, storing its id
|
||||
@@ -72,13 +83,15 @@ func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
// else, including a calendar chosen earlier that has since been deleted or
|
||||
// is no longer writable, is ErrUnknownCalendar and leaves the stored
|
||||
// choice as it was. Choosing another calendar than the one chosen returns
|
||||
// every plan pushed to that one to not synced; see saveSelection.
|
||||
// every plan pushed to that one to not synced; see saveSelection. A grant
|
||||
// Google refuses is ErrReconnect, recorded as reconnect required
|
||||
// (recordRefusal).
|
||||
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
cals, err := s.calendars(ctx)
|
||||
if err != nil {
|
||||
return Selection{}, err
|
||||
return Selection{}, s.recordRefusal(ctx, err)
|
||||
}
|
||||
for _, c := range cals {
|
||||
if c.ID != id {
|
||||
@@ -194,21 +207,39 @@ func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// accessToken trades row's refresh token for an access token. A grant
|
||||
// Google reports as invalid_grant is ErrReconnect.
|
||||
// accessToken trades row's refresh token for an access token, retrying a
|
||||
// transient failure as send does. A grant Google reports as invalid_grant,
|
||||
// or one already recorded as refused (no refresh token stored), is
|
||||
// ErrReconnect.
|
||||
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
|
||||
if row.RefreshTokenCt == nil {
|
||||
return "", ErrReconnect
|
||||
}
|
||||
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gcal: open refresh token: %w", err)
|
||||
}
|
||||
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
|
||||
&oauth2.Token{RefreshToken: string(refresh)}).Token()
|
||||
if err != nil {
|
||||
for attempt := 0; ; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := s.backoff(ctx, attempt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
|
||||
&oauth2.Token{RefreshToken: string(refresh)}).Token()
|
||||
if err == nil {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
var retrieve *oauth2.RetrieveError
|
||||
var transport *url.Error
|
||||
switch {
|
||||
case errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant":
|
||||
return "", ErrReconnect
|
||||
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode) && attempt < maxRetries:
|
||||
continue
|
||||
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode):
|
||||
return "", &CalendarError{retryable: true,
|
||||
msg: fmt.Sprintf("gcal: Google could not take the token refresh (HTTP %d) in %d tries", retrieve.Response.StatusCode, maxRetries+1)}
|
||||
case errors.As(err, &retrieve):
|
||||
return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)}
|
||||
case errors.As(err, &transport):
|
||||
@@ -219,7 +250,6 @@ func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (s
|
||||
return "", &CalendarError{msg: "gcal: token refresh returned an unusable response"}
|
||||
}
|
||||
}
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
// calendarListPage is the part of a calendarList page MealQuest reads.
|
||||
@@ -234,35 +264,29 @@ type calendarListPage struct {
|
||||
}
|
||||
|
||||
// calendarPage fetches the calendarList page at pageToken ("" for the
|
||||
// first) with access. HTTP 401 means Google no longer accepts the grant.
|
||||
// first) with access. HTTP 401 means Google no longer accepts the grant:
|
||||
// the access token was just refreshed from it.
|
||||
func (s *Service) calendarPage(ctx context.Context, access, pageToken string) (calendarListPage, error) {
|
||||
q := url.Values{"maxResults": {fmt.Sprint(calendarPageSize)}}
|
||||
if pageToken != "" {
|
||||
q.Set("pageToken", pageToken)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return calendarListPage{}, fmt.Errorf("gcal: build calendar list request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list request failed: %s: %v", transport.Op, transport.Err)}
|
||||
status, body, err := s.send(ctx, "calendar list", maxCalendarBody+1, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return calendarListPage{}, &CalendarError{msg: "gcal: calendar list request failed"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCalendarBody+1))
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return calendarListPage{}, &CalendarError{msg: "gcal: read calendar list failed"}
|
||||
return calendarListPage{}, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case status == http.StatusUnauthorized:
|
||||
return calendarListPage{}, ErrReconnect
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", resp.StatusCode)}
|
||||
case status != http.StatusOK:
|
||||
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", status)}
|
||||
case len(body) > maxCalendarBody:
|
||||
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)}
|
||||
}
|
||||
|
||||
+17
-11
@@ -122,6 +122,9 @@ type Service struct {
|
||||
calendarAPI string
|
||||
client *http.Client
|
||||
cookie auth.FlowCookie
|
||||
// retryDelay is the wait before the first retry of a transient
|
||||
// failure; tests shorten it.
|
||||
retryDelay time.Duration
|
||||
// now is the clock; tests replace it.
|
||||
now func() time.Time
|
||||
|
||||
@@ -170,6 +173,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
|
||||
calendarAPI: cfg.CalendarAPI,
|
||||
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
|
||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||
retryDelay: firstRetryDelay,
|
||||
now: time.Now,
|
||||
pending: map[string]time.Time{},
|
||||
}, nil
|
||||
@@ -343,6 +347,9 @@ func (s *Service) store(ctx context.Context, sealed []byte) error {
|
||||
// MealQuest's access in their Google account) counts as revoked. Any other
|
||||
// revoke failure keeps the connection so the household can retry.
|
||||
//
|
||||
// A connection whose grant Google already refused (reconnect required, no
|
||||
// refresh token stored) has nothing to revoke and is deleted.
|
||||
//
|
||||
// No calendar is chosen once disconnected, so the same transaction that
|
||||
// deletes the connection forgets every pushed event (forgetEvents): every
|
||||
// synced plan becomes changed, and the next push after connecting again and
|
||||
@@ -350,20 +357,19 @@ func (s *Service) store(ctx context.Context, sealed []byte) error {
|
||||
func (s *Service) Disconnect(ctx context.Context) error {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotConnected
|
||||
}
|
||||
row, err := s.connection(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: read Google integration: %w", err)
|
||||
}
|
||||
token, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: open refresh token: %w", err)
|
||||
}
|
||||
if err := s.revoke(ctx, string(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
if row.RefreshTokenCt != nil {
|
||||
token, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: open refresh token: %w", err)
|
||||
}
|
||||
if err := s.revoke(ctx, string(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
if err := q.DeleteIntegrationsGoogle(ctx); err != nil {
|
||||
|
||||
@@ -80,6 +80,7 @@ func newHarness(t *testing.T) *harness {
|
||||
}
|
||||
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
|
||||
gcal.SetClock(svc, func() time.Time { return h.clock })
|
||||
gcal.SetRetryDelay(svc, 0)
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
+29
-28
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -84,7 +83,9 @@ type eventTime struct {
|
||||
// is safe. Nothing connected is ErrNotConnected, no calendar chosen is
|
||||
// ErrNoCalendar, a chosen calendar that was deleted or is no longer
|
||||
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
|
||||
// ErrReconnect, and any other Google failure is a *CalendarError.
|
||||
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
|
||||
// other Google failure is a *CalendarError, retryable when Google answered
|
||||
// every try with HTTP 429 or 5xx.
|
||||
//
|
||||
// Once every event is written, PushEvents returns record(), which the
|
||||
// caller uses to store what it pushed. record runs under the same hold of
|
||||
@@ -101,6 +102,12 @@ func (s *Service) PushEvents(ctx context.Context, events []Event, record func()
|
||||
}
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
|
||||
}
|
||||
|
||||
// pushEvents is PushEvents once the events are valid. The caller holds
|
||||
// s.account.
|
||||
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
|
||||
row, err := s.connection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -148,43 +155,37 @@ func (s *Service) writeEvent(ctx context.Context, access, base string, e Event)
|
||||
return err
|
||||
}
|
||||
|
||||
// eventRequest sends an event insert (POST) or update (PUT) and returns
|
||||
// HTTP 200 or, for an insert, 409; every other answer is an error.
|
||||
// eventRequest sends an event insert (POST) or update (PUT), retrying a
|
||||
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
|
||||
// every other answer is an error. An insert Google took but answered with
|
||||
// a transient failure is answered 409 when retried, and updated.
|
||||
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
|
||||
what := "event insert"
|
||||
if method == http.MethodPut {
|
||||
what = "event update"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gcal: build %s request: %w", what, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
// The URL is dropped: after a refused redirect it is the
|
||||
// Google-supplied Location.
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
|
||||
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
reply, err := io.ReadAll(io.LimitReader(resp.Body, maxEventBody))
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
|
||||
return 0, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
case status == http.StatusOK:
|
||||
return http.StatusOK, nil
|
||||
case resp.StatusCode == http.StatusConflict && method == http.MethodPost:
|
||||
case status == http.StatusConflict && method == http.MethodPost:
|
||||
return http.StatusConflict, nil
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case status == http.StatusUnauthorized:
|
||||
// The access token was just refreshed from the grant.
|
||||
return 0, ErrReconnect
|
||||
case resp.StatusCode == http.StatusNotFound && method == http.MethodPost,
|
||||
resp.StatusCode == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
|
||||
case status == http.StatusNotFound && method == http.MethodPost,
|
||||
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
|
||||
// An insert into a deleted calendar, or one the account no longer
|
||||
// sees, is not found; a write to one it can only read is
|
||||
// requiredAccessLevel. An update not found is the event missing
|
||||
@@ -192,7 +193,7 @@ func (s *Service) eventRequest(ctx context.Context, access, method, target strin
|
||||
// says nothing about the calendar.
|
||||
return 0, ErrCalendarUnavailable
|
||||
}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, resp.StatusCode)}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
|
||||
}
|
||||
|
||||
// errorReason is the reason of the first error in a Calendar API error
|
||||
|
||||
@@ -117,13 +117,13 @@ func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// The fake's failure body quotes the access token.
|
||||
h.google.FailEventWrite(n, http.StatusInternalServerError)
|
||||
h.google.FailEventWrite(n, http.StatusBadRequest)
|
||||
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
|
||||
var calendar *gcal.CalendarError
|
||||
if !errors.As(err, &calendar) {
|
||||
t.Fatalf("PushEvents = %v, want *CalendarError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 500") {
|
||||
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("error = %q, want the failed %s and its status", err, name)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
|
||||
@@ -4,3 +4,7 @@ import "time"
|
||||
|
||||
// SetClock replaces s's clock.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
|
||||
// SetRetryDelay replaces s's wait before the first retry of a transient
|
||||
// Google failure.
|
||||
func SetRetryDelay(s *Service, d time.Duration) { s.retryDelay = d }
|
||||
|
||||
@@ -76,17 +76,22 @@ type Google struct {
|
||||
calendars []Calendar
|
||||
pageSize int
|
||||
// calendarStatus, when not 200, fails the calendarList endpoint with a
|
||||
// body that quotes the access token.
|
||||
// body that quotes the access token: every request when calendarFails
|
||||
// is 0, else the next calendarFails requests.
|
||||
calendarStatus int
|
||||
calendarFails int
|
||||
calendarCalls int
|
||||
// rejectAccess makes the Calendar API answer HTTP 401 to every access
|
||||
// token, while the grant stays live at the token endpoint.
|
||||
rejectAccess bool
|
||||
// events holds each calendar's events by id.
|
||||
events map[string]map[string]Event
|
||||
// inserts and updates count the events created and updated.
|
||||
inserts, updates int
|
||||
// eventWrites counts the requests that reached the events endpoints;
|
||||
// failWrite, when not 0, fails the request that makes it that count
|
||||
// with failStatus and a body that quotes the access token.
|
||||
eventWrites, failWrite, failStatus int
|
||||
// failWrite, when not 0, fails the requests that make it that count up
|
||||
// to failLast with failStatus and a body that quotes the access token.
|
||||
eventWrites, failWrite, failLast, failStatus int
|
||||
// eventHold, when set, stops the next events request.
|
||||
eventHold *hold
|
||||
}
|
||||
@@ -257,7 +262,24 @@ func (g *Google) SetCalendars(pageSize int, cals ...Calendar) {
|
||||
func (g *Google) FailCalendarList(status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.calendarStatus = status
|
||||
g.calendarStatus, g.calendarFails = status, 0
|
||||
}
|
||||
|
||||
// FailCalendarListNext makes the next n calendarList requests answer status
|
||||
// as FailCalendarList does, and later ones answer normally.
|
||||
func (g *Google) FailCalendarListNext(n, status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.calendarStatus, g.calendarFails = status, n
|
||||
}
|
||||
|
||||
// RejectAccessToken makes the Calendar API answer HTTP 401 to every access
|
||||
// token while refreshing the grant still succeeds, as Google does when it
|
||||
// refuses the access token it just issued.
|
||||
func (g *Google) RejectAccessToken() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.rejectAccess = true
|
||||
}
|
||||
|
||||
// CalendarCalls is how many requests reached the calendarList endpoint.
|
||||
@@ -315,9 +337,23 @@ func (g *Google) CancelEvent(calendar, id string) {
|
||||
// FailEventWrite makes the nth request to the events endpoints from now
|
||||
// (1 for the next) answer status with a body that quotes the access token.
|
||||
func (g *Google) FailEventWrite(n, status int) {
|
||||
g.FailEventWrites(n, 1, status)
|
||||
}
|
||||
|
||||
// FailEventWrites makes count requests to the events endpoints in a row,
|
||||
// from the nth from now (1 for the next), answer status with a body that
|
||||
// quotes the access token.
|
||||
func (g *Google) FailEventWrites(n, count, status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.failWrite, g.failStatus = g.eventWrites+n, status
|
||||
g.failWrite, g.failLast, g.failStatus = g.eventWrites+n, g.eventWrites+n+count-1, status
|
||||
}
|
||||
|
||||
// EventRequests is how many requests reached the events endpoints.
|
||||
func (g *Google) EventRequests() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.eventWrites
|
||||
}
|
||||
|
||||
// HoldEvents stops the next request to the events endpoints before it is
|
||||
@@ -476,16 +512,22 @@ func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
|
||||
g.calendarHold = nil
|
||||
g.wait(h)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer "+AccessToken || !g.grantLive() {
|
||||
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":{"code":401,"message":"Invalid Credentials: %s"}}`, auth)
|
||||
return
|
||||
}
|
||||
if g.calendarStatus != http.StatusOK {
|
||||
if status := g.calendarStatus; status != http.StatusOK {
|
||||
if g.calendarFails > 0 {
|
||||
g.calendarFails--
|
||||
if g.calendarFails == 0 {
|
||||
g.calendarStatus = http.StatusOK
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(g.calendarStatus)
|
||||
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, g.calendarStatus, auth)
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, status, auth)
|
||||
return
|
||||
}
|
||||
start := 0
|
||||
@@ -598,11 +640,11 @@ func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string,
|
||||
g.eventHold = nil
|
||||
g.wait(h)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer "+AccessToken || !g.grantLive() {
|
||||
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
|
||||
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
|
||||
return "", false
|
||||
}
|
||||
if g.failWrite != 0 && g.eventWrites == g.failWrite {
|
||||
if g.failWrite != 0 && g.eventWrites >= g.failWrite && g.eventWrites <= g.failLast {
|
||||
apiError(w, g.failStatus, "backendError", "echo "+auth)
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// ReconnectReason is the last_sync_error stored when Google refuses the
|
||||
// stored grant.
|
||||
const ReconnectReason = "Google access was revoked or expired — reconnect"
|
||||
|
||||
const (
|
||||
// maxRetries is how many times a request Google answers with a
|
||||
// transient failure (HTTP 429 or 5xx) is sent again.
|
||||
maxRetries = 2
|
||||
// firstRetryDelay is the wait before the first retry; each later retry
|
||||
// waits twice as long as the one before.
|
||||
firstRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// The states Status reports.
|
||||
const (
|
||||
// StatusNotConnected: no Google account is connected.
|
||||
StatusNotConnected = "not_connected"
|
||||
// StatusConnected: a Google account is connected and its grant was last
|
||||
// accepted.
|
||||
StatusConnected = "connected"
|
||||
// StatusReconnectRequired: Google refused the stored grant, so it was
|
||||
// cleared; only connecting again restores Push to Calendar.
|
||||
StatusReconnectRequired = "reconnect_required"
|
||||
)
|
||||
|
||||
// Status is the Google Calendar connection as the household sees it. It
|
||||
// never holds a token.
|
||||
type Status struct {
|
||||
State string `json:"status"`
|
||||
CalendarID *string `json:"calendar_id"`
|
||||
CalendarName *string `json:"calendar_name"`
|
||||
ConnectedAt *string `json:"connected_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
}
|
||||
|
||||
// Status reports the connection: not connected, connected, or reconnect
|
||||
// required, with the chosen calendar, when it was connected and the last
|
||||
// sync error. It is one read of one row, so it takes no lock.
|
||||
func (s *Service) Status(ctx context.Context) (Status, error) {
|
||||
row, err := s.connection(ctx)
|
||||
if errors.Is(err, ErrNotConnected) {
|
||||
return Status{State: StatusNotConnected}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
st := Status{
|
||||
State: StatusConnected,
|
||||
CalendarID: nullable(row.CalendarID),
|
||||
CalendarName: nullable(row.CalendarName),
|
||||
ConnectedAt: &row.ConnectedAt,
|
||||
LastSyncError: nullable(row.LastSyncError),
|
||||
}
|
||||
if row.RefreshTokenCt == nil {
|
||||
st.State = StatusReconnectRequired
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func nullable(v sql.NullString) *string {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
return &v.String
|
||||
}
|
||||
|
||||
// recordRefusal returns err, first recording, when it is ErrReconnect, that
|
||||
// Google refuses the stored grant: the refresh token is cleared and
|
||||
// last_sync_error set to ReconnectReason, so Status reports reconnect
|
||||
// required and no request offers Google the refused token again.
|
||||
//
|
||||
// The chosen calendar and every stored calendar event id are kept, and
|
||||
// forgetEvents is deliberately not run: the events MealQuest pushed are
|
||||
// still on the chosen calendar, so a stored calendar_event_id still names
|
||||
// an event on the chosen calendar. Reconnecting keeps the choice (Complete),
|
||||
// and the next push updates those events in place. The caller holds
|
||||
// s.account, which every push and calendar choice also holds, so the
|
||||
// refusal is recorded between them, never during one.
|
||||
func (s *Service) recordRefusal(ctx context.Context, err error) error {
|
||||
if !errors.Is(err, ErrReconnect) {
|
||||
return err
|
||||
}
|
||||
cleared := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
n, err := db.New(tx).ClearIntegrationsGoogleToken(ctx, db.ClearIntegrationsGoogleTokenParams{
|
||||
LastSyncError: sql.NullString{String: ReconnectReason, Valid: true},
|
||||
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
// The lock is held since the connection was read, so only a
|
||||
// change outside this service gets here.
|
||||
return ErrNotConnected
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if cleared != nil {
|
||||
return fmt.Errorf("gcal: record that Google refused the grant: %w", cleared)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// transient reports whether Google's HTTP status is a failure worth
|
||||
// retrying: rate limited (429) or a server error (5xx).
|
||||
func transient(status int) bool {
|
||||
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// backoff waits before retry number attempt (1 for the first), or until ctx
|
||||
// ends.
|
||||
func (s *Service) backoff(ctx context.Context, attempt int) error {
|
||||
t := time.NewTimer(s.retryDelay << (attempt - 1))
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("gcal: wait to retry: %w", ctx.Err())
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// send sends the request newRequest builds, and sends it again, up to
|
||||
// maxRetries times, while Google answers it with a transient failure. It
|
||||
// returns the final answer's status and up to limit bytes of its body. A
|
||||
// transient failure on the last try is a retryable *CalendarError; what
|
||||
// names the request in errors, which never hold the URL, a token or
|
||||
// Google's response body.
|
||||
func (s *Service) send(ctx context.Context, what string, limit int64, newRequest func() (*http.Request, error)) (int, []byte, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := s.backoff(ctx, attempt); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
req, err := newRequest()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gcal: build %s request: %w", what, err)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
// The URL is dropped: after a refused redirect it is the
|
||||
// Google-supplied Location.
|
||||
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
|
||||
}
|
||||
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
|
||||
}
|
||||
if !transient(resp.StatusCode) {
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
if attempt == maxRetries {
|
||||
return 0, nil, &CalendarError{retryable: true,
|
||||
msg: fmt.Sprintf("gcal: Google could not take the %s (HTTP %d) in %d tries", what, resp.StatusCode, maxRetries+1)}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
+96
-10
@@ -360,13 +360,13 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
// Google fails the fourth event.
|
||||
e.google.FailEventWrite(4, http.StatusServiceUnavailable)
|
||||
// Google fails the fourth event on every try.
|
||||
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable)
|
||||
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
var calendar *gcal.CalendarError
|
||||
if !errors.As(err, &calendar) {
|
||||
t.Fatalf("Push = %v, want *gcal.CalendarError", err)
|
||||
if !errors.As(err, &calendar) || !calendar.Retryable() {
|
||||
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if n := len(e.google.Events(dinnersCal.ID)); n != 3 {
|
||||
@@ -402,7 +402,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e.google.FailEventWrite(2, http.StatusInternalServerError)
|
||||
e.google.FailEventWrite(2, http.StatusBadRequest)
|
||||
if _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
t.Fatal("Push succeeded, want the second event to fail")
|
||||
}
|
||||
@@ -450,11 +450,97 @@ func TestPushGrantNoLongerAccepted(t *testing.T) {
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
// The connection is left as it is: forgetting it is not the push's to
|
||||
// decide.
|
||||
if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); err != nil {
|
||||
t.Errorf("connection after a refused grant: %v", err)
|
||||
e.assertRefused(t)
|
||||
}
|
||||
|
||||
// assertRefused fails unless the connection is recorded as reconnect
|
||||
// required: its refresh token cleared, last_sync_error the reason, and the
|
||||
// chosen calendar kept.
|
||||
func (e *env) assertRefused(t *testing.T) {
|
||||
t.Helper()
|
||||
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("connection after a refused grant: %v", err)
|
||||
}
|
||||
if row.RefreshTokenCt != nil || row.LastSyncError.String != gcal.ReconnectReason || row.CalendarID.String != dinnersCal.ID {
|
||||
t.Errorf("connection after a refused grant: token %x, last_sync_error %v, calendar %v; want no token, %q, %s",
|
||||
row.RefreshTokenCt, row.LastSyncError, row.CalendarID, gcal.ReconnectReason, dinnersCal.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedGrantKeepsPushedWeeks: a grant Google refuses clears the
|
||||
// refresh token but keeps the chosen calendar, where every pushed event
|
||||
// still is, so every pushed week stays synced and keeps its event ids.
|
||||
// Connecting again keeps the calendar, and the next push updates the
|
||||
// events in place.
|
||||
func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
e.google.RevokeGrant()
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push = %v, want ErrReconnect", err)
|
||||
}
|
||||
e.assertRefused(t)
|
||||
for _, w := range pushedWeeks {
|
||||
if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced {
|
||||
t.Errorf("plan of %s after a refused grant = %s, %v; want synced", w, p.Status, err)
|
||||
}
|
||||
e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...)
|
||||
}
|
||||
|
||||
// The refused token is not offered to Google again.
|
||||
before := e.google.TokenCalls()
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push again = %v, want ErrReconnect", err)
|
||||
}
|
||||
if n := e.google.TokenCalls() - before; n != 0 {
|
||||
t.Errorf("token endpoint called %d times after the grant was refused", n)
|
||||
}
|
||||
|
||||
e.google.Connect(t, e.gcal)
|
||||
e.google.ResetEventWrites()
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
|
||||
t.Errorf("push after connecting again: %d inserts and %d updates, want 0 and 7", inserts, updates)
|
||||
}
|
||||
e.assertWeekOn(t, dinnersCal.ID, week, dates[week], fullWeek...)
|
||||
}
|
||||
|
||||
// TestDisconnectAfterRefusedGrant: a connection whose grant Google refused
|
||||
// has no token to revoke; disconnecting deletes it and forgets every pushed
|
||||
// event, as any disconnect does.
|
||||
func TestDisconnectAfterRefusedGrant(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.google.RevokeGrant()
|
||||
if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||||
}
|
||||
e.assertRefused(t)
|
||||
if err := e.gcal.Disconnect(t.Context()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if n := len(e.google.Revoked()); n != 0 {
|
||||
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
|
||||
}
|
||||
if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("connection after disconnecting = %v, want none", err)
|
||||
}
|
||||
e.assertForgotten(t, week, "after disconnecting")
|
||||
}
|
||||
|
||||
func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
|
||||
@@ -553,7 +639,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
|
||||
|
||||
// A push that fails part way leaves the plan changed.
|
||||
e.google.ResetEventWrites()
|
||||
e.google.FailEventWrite(4, http.StatusServiceUnavailable)
|
||||
e.google.FailEventWrite(4, http.StatusBadRequest)
|
||||
if _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
t.Fatal("Push succeeded, want the fourth event to fail")
|
||||
}
|
||||
|
||||
+41
-11
@@ -147,9 +147,8 @@ func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc
|
||||
|
||||
// writeGoogleCalendarError maps a calendar listing or choice error to its
|
||||
// API error: 409 not_connected when nothing is connected, 422
|
||||
// unknown_calendar for a calendar MealQuest cannot push to, 502
|
||||
// reconnect_required when Google no longer accepts the stored grant, and 502
|
||||
// google_failed when Google refused or could not be reached.
|
||||
// unknown_calendar for a calendar MealQuest cannot push to, and a Google
|
||||
// failure as writeGoogleFailure does.
|
||||
func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var calendar *gcal.CalendarError
|
||||
switch {
|
||||
@@ -158,16 +157,47 @@ func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *sl
|
||||
case errors.Is(err, gcal.ErrUnknownCalendar):
|
||||
writeError(w, r, logger, http.StatusUnprocessableEntity, "unknown_calendar",
|
||||
"choose one of your calendars that MealQuest can add dinners to")
|
||||
case errors.Is(err, gcal.ErrReconnect):
|
||||
logger.WarnContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
|
||||
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
|
||||
case errors.As(err, &calendar):
|
||||
logger.WarnContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
|
||||
"Google Calendar could not be reached; try again")
|
||||
case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar):
|
||||
writeGoogleFailure(w, r, logger, action, err, "Google Calendar could not be reached; try again")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
// writeGoogleFailure answers err, gcal.ErrReconnect or a *gcal.CalendarError,
|
||||
// the way every Google Calendar call does: 409 google_reconnect_required when
|
||||
// Google refused the stored grant (recorded as reconnect required, so only
|
||||
// connecting again helps), 503 google_unavailable, retryable, when Google
|
||||
// answered every try with HTTP 429 or 5xx (nothing was disconnected), and
|
||||
// 502 google_failed, with failed as its message, for any other failure.
|
||||
func writeGoogleFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error, failed string) {
|
||||
logger.WarnContext(r.Context(), action, "error", err)
|
||||
var calendar *gcal.CalendarError
|
||||
switch {
|
||||
case errors.Is(err, gcal.ErrReconnect):
|
||||
writeError(w, r, logger, http.StatusConflict, "google_reconnect_required",
|
||||
"Google access was revoked or expired; connect Google Calendar again")
|
||||
case errors.As(err, &calendar) && calendar.Retryable():
|
||||
writeAPIError(w, r, logger, http.StatusServiceUnavailable, apiError{Code: "google_unavailable",
|
||||
Message: "Google Calendar is busy right now; try again in a few minutes", Retryable: true})
|
||||
default:
|
||||
writeError(w, r, logger, http.StatusBadGateway, "google_failed", failed)
|
||||
}
|
||||
}
|
||||
|
||||
// getGoogle answers the Google Calendar connection's status: {status,
|
||||
// calendar_id, calendar_name, connected_at, last_sync_error}, where status
|
||||
// is not_connected, connected or reconnect_required.
|
||||
func getGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
st, err := svc.Status(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read Google Calendar status", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, st)
|
||||
}
|
||||
}
|
||||
|
||||
+100
-17
@@ -413,41 +413,124 @@ func TestGoogleCalendarsNotConnected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoogleCalendarsGrantRevoked lists and chooses after the household
|
||||
// removed MealQuest's access in their Google account: 502
|
||||
// reconnect_required, and the connection is kept for the household to
|
||||
// reconnect or disconnect.
|
||||
// getGoogleStatus answers GET /integrations/google, decoded.
|
||||
func (e *googleEnv) getGoogleStatus(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.serve(signedIn(http.MethodGet, googlePath, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d %s, want 200", googlePath, rec.Code, rec.Body)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("GET %s Cache-Control = %q, want no-store", googlePath, cc)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestGoogleCalendarsGrantRevoked lists and chooses after Google stopped
|
||||
// accepting MealQuest's grant — the household removed its access in their
|
||||
// Google account (invalid_grant), or Google refuses the access token just
|
||||
// refreshed (HTTP 401): 409 google_reconnect_required, the refresh token is
|
||||
// cleared, and GET /integrations/google reports reconnect_required with the
|
||||
// reason, keeping the chosen calendar.
|
||||
func TestGoogleCalendarsGrantRevoked(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, refuse := range map[string]func(*gcaltest.Google){
|
||||
"invalid_grant": (*gcaltest.Google).RevokeGrant,
|
||||
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
e.connected(t)
|
||||
if rec := e.serve(chooseCalendar("family@group.calendar.google.com")); rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT calendar = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
refuse(e.google)
|
||||
for _, req := range []*http.Request{
|
||||
signedIn(http.MethodGet, googleCalendarsPath, nil),
|
||||
chooseCalendar("household@gmail.com"),
|
||||
} {
|
||||
rec := e.serve(req)
|
||||
code, message := errorBodyCode(t, rec)
|
||||
if rec.Code != http.StatusConflict || code != "google_reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
|
||||
t.Errorf("%s %s = %d %s, want 409 google_reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
got := e.getGoogleStatus(t)
|
||||
if got["status"] != "reconnect_required" || got["last_sync_error"] != "Google access was revoked or expired — reconnect" ||
|
||||
got["calendar_id"] != "family@group.calendar.google.com" {
|
||||
t.Errorf("GET %s = %v, want reconnect_required with the reason and the calendar kept", googlePath, got)
|
||||
}
|
||||
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
|
||||
if err != nil || row.RefreshTokenCt != nil {
|
||||
t.Errorf("connection = %+v, %v; want it kept without a refresh token", row, err)
|
||||
}
|
||||
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoogleCalendarsTransientFailure fails the calendar list with HTTP 503
|
||||
// on every try: it is tried three times, then 503 google_unavailable,
|
||||
// retryable, and the connection stays connected.
|
||||
func TestGoogleCalendarsTransientFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
e.connected(t)
|
||||
e.google.RevokeGrant()
|
||||
for _, req := range []*http.Request{
|
||||
signedIn(http.MethodGet, googleCalendarsPath, nil),
|
||||
chooseCalendar("family@group.calendar.google.com"),
|
||||
} {
|
||||
rec := e.serve(req)
|
||||
code, message := errorBodyCode(t, rec)
|
||||
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
|
||||
t.Errorf("%s %s = %d %s, want 502 reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
|
||||
}
|
||||
e.google.FailCalendarList(http.StatusServiceUnavailable)
|
||||
calls := e.google.CalendarCalls()
|
||||
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
|
||||
var body struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusServiceUnavailable || body.Error.Code != "google_unavailable" || !body.Error.Retryable {
|
||||
t.Errorf("GET calendars = %d %s, want 503 google_unavailable, retryable", rec.Code, rec.Body)
|
||||
}
|
||||
if n := e.google.CalendarCalls() - calls; n != 3 {
|
||||
t.Errorf("calendar list tried %d times, want 3", n)
|
||||
}
|
||||
if got := e.getGoogleStatus(t); got["status"] != "connected" || got["last_sync_error"] != nil {
|
||||
t.Errorf("GET %s = %v, want connected with no error", googlePath, got)
|
||||
}
|
||||
if !e.stored(t) {
|
||||
t.Error("the connection was cleared")
|
||||
}
|
||||
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
|
||||
// newGoogleEnv's cleanup checks the response and logs hold no secret.
|
||||
}
|
||||
|
||||
func TestGoogleStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
if got := e.getGoogleStatus(t); got["status"] != "not_connected" {
|
||||
t.Errorf("GET %s = %v, want not_connected", googlePath, got)
|
||||
}
|
||||
e.connected(t)
|
||||
got := e.getGoogleStatus(t)
|
||||
if got["status"] != "connected" || got["connected_at"] == nil || got["calendar_id"] != nil || got["last_sync_error"] != nil {
|
||||
t.Errorf("GET %s = %v, want connected with no calendar", googlePath, got)
|
||||
}
|
||||
if len(got) != 5 {
|
||||
t.Errorf("GET %s = %v, want exactly status, calendar_id, calendar_name, connected_at and last_sync_error", googlePath, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleCalendarsFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
e.connected(t)
|
||||
e.google.FailCalendarList(http.StatusInternalServerError)
|
||||
e.google.FailCalendarList(http.StatusForbidden)
|
||||
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
|
||||
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
|
||||
t.Errorf("GET calendars = %d %s, want 502 google_failed", rec.Code, rec.Body)
|
||||
}
|
||||
if !strings.Contains(e.logs.String(), "HTTP 500") {
|
||||
if !strings.Contains(e.logs.String(), "HTTP 403") {
|
||||
t.Errorf("logs = %s, want the failure's status", e.logs)
|
||||
}
|
||||
// newGoogleEnv's cleanup checks the response and logs hold no secret.
|
||||
|
||||
@@ -115,9 +115,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
// not_connected when Google Calendar is not connected, no_calendar when no
|
||||
// calendar is chosen, calendar_unavailable when the chosen calendar was
|
||||
// deleted or is no longer writable, and plan_changed when the plan was
|
||||
// edited during the push; 502 reconnect_required when Google no longer
|
||||
// accepts the grant, and google_failed when Google refused or could not be
|
||||
// reached. A failed push leaves the plan's status as it was. Any other
|
||||
// edited during the push; a Google failure as writeGoogleFailure maps it
|
||||
// (409 google_reconnect_required, 503 google_unavailable or 502
|
||||
// google_failed). A failed push leaves the plan's status as it was. Any other
|
||||
// error is the plan's, as writePlanError maps it.
|
||||
func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) {
|
||||
var calendar *gcal.CalendarError
|
||||
@@ -133,14 +133,8 @@ func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Log
|
||||
"the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings")
|
||||
case errors.Is(err, plan.ErrPlanChanged):
|
||||
writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error())
|
||||
case errors.Is(err, gcal.ErrReconnect):
|
||||
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
|
||||
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
|
||||
case errors.As(err, &calendar):
|
||||
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
|
||||
"Google Calendar could not be reached; push again")
|
||||
case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar):
|
||||
writeGoogleFailure(w, r, logger, "push plan to Calendar", err, "Google Calendar could not be reached; push again")
|
||||
default:
|
||||
writePlanError(w, r, logger, "push plan to Calendar", err)
|
||||
}
|
||||
|
||||
@@ -429,10 +429,18 @@ func TestPushPlanToCalendarErrors(t *testing.T) {
|
||||
{"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||||
e.connectCalendar(t, true)
|
||||
e.google.RevokeGrant()
|
||||
}, http.StatusBadGateway, "reconnect_required"},
|
||||
}, http.StatusConflict, "google_reconnect_required"},
|
||||
{"401 after a refresh", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||||
e.connectCalendar(t, true)
|
||||
e.google.RejectAccessToken()
|
||||
}, http.StatusConflict, "google_reconnect_required"},
|
||||
{"Google unavailable", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||||
e.connectCalendar(t, true)
|
||||
e.google.FailEventWrites(3, 3, http.StatusServiceUnavailable)
|
||||
}, http.StatusServiceUnavailable, "google_unavailable"},
|
||||
{"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||||
e.connectCalendar(t, true)
|
||||
e.google.FailEventWrite(3, http.StatusServiceUnavailable)
|
||||
e.google.FailEventWrite(3, http.StatusBadRequest)
|
||||
}, http.StatusBadGateway, "google_failed"},
|
||||
{"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"},
|
||||
{"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"},
|
||||
@@ -445,6 +453,15 @@ func TestPushPlanToCalendarErrors(t *testing.T) {
|
||||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
|
||||
}
|
||||
if apiErr, _ := body["error"].(map[string]any); apiErr["retryable"] != (tc.code == "google_unavailable") {
|
||||
t.Errorf("push = %s, want retryable only when Google is unavailable", rec.Body)
|
||||
}
|
||||
if tc.code == "google_reconnect_required" {
|
||||
_, status := callDinners(t, e.h, http.MethodGet, googlePath, "")
|
||||
if status["status"] != "reconnect_required" || status["last_sync_error"] != gcal.ReconnectReason {
|
||||
t.Errorf("GET %s after the push = %v, want reconnect_required with the reason", googlePath, status)
|
||||
}
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -47,8 +47,8 @@ type RouteMounter interface {
|
||||
// /api/v1/plans/{weekStart}/draft, PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET
|
||||
// /api/v1/integrations/google/connect, /callback and /calendars, PUT
|
||||
// /api/v1/integrations/google/calendar and DELETE
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||
// /api/v1/integrations/google; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
@@ -141,6 +141,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
// The callback's state is bound to the session that started connect.
|
||||
apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google", getGoogle(logger, googleSvc))
|
||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||
|
||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -718,3 +718,46 @@ func TestMigrateRotationReviewName(t *testing.T) {
|
||||
t.Error("rotation_reviews_copy left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateGoogleReconnectKeepsConnection applies 0015, which rebuilds
|
||||
// integrations_google, over a stored connection: every column survives.
|
||||
func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
matches, err := fs.Glob(migrations.FS, "*.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range matches {
|
||||
if name >= "0015" {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const row = `1|token|family@group.calendar.google.com|Family|2026-10-01T00:00:00.000Z|earlier error|2026-09-01T00:00:00.000Z|2026-10-02T00:00:00.000Z`
|
||||
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO integrations_google VALUES (1, CAST('token' AS BLOB),
|
||||
'family@group.calendar.google.com', 'Family', '2026-10-01T00:00:00.000Z', 'earlier error',
|
||||
'2026-09-01T00:00:00.000Z', '2026-10-02T00:00:00.000Z')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got string
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT id || '|' || CAST(refresh_token_ct AS TEXT) || '|' || calendar_id || '|' ||
|
||||
calendar_name || '|' || connected_at || '|' || last_sync_error || '|' || created_at || '|' || updated_at
|
||||
FROM integrations_google`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != row {
|
||||
t.Errorf("connection after 0015 = %s, want %s", got, row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +516,24 @@ func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaGoogleTokenClearedOnlyWithReason: a Google connection may lose
|
||||
// its refresh token (reconnect required) only with last_sync_error saying
|
||||
// why.
|
||||
func TestSchemaGoogleTokenClearedOnlyWithReason(t *testing.T) {
|
||||
s := migratedStore(t)
|
||||
row := testRow("integrations_google", 0)
|
||||
row["refresh_token_ct"] = nil
|
||||
inRolledBackTx(t, s, func(tx *sql.Tx) {
|
||||
wantConstraintError(t, insertSchemaRow(tx, "integrations_google", row), "CHECK", "integrations_google without a token or a reason")
|
||||
})
|
||||
row["last_sync_error"] = "Google access was revoked or expired — reconnect"
|
||||
inRolledBackTx(t, s, func(tx *sql.Tx) {
|
||||
if err := insertSchemaRow(tx, "integrations_google", row); err != nil {
|
||||
t.Errorf("integrations_google without a token, with a reason: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) {
|
||||
cases := []struct{ parent, child, column string }{
|
||||
{"recipes", "recipe_ingredients", "recipe_id"},
|
||||
|
||||
Reference in New Issue
Block a user