diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 59a4538..d8b64d0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -639,11 +639,17 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. with `secretbox` into `integrations_google.refresh_token_ct`, sets `connected_at` and redirects to `/settings/calendar`. A reconnect keeps the chosen calendar and clears `last_sync_error`. + - `GET /api/v1/integrations/google` answers `{status, calendar_id, + calendar_name, connected_at, last_sync_error}`, where `status` is + `not_connected`, `connected` or `reconnect_required` (connected, but + Google refused the grant and its refresh token was cleared); absent + values are `null`. It never holds a token. - `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at Google, then deletes the row and, in the same transaction, forgets every pushed event (see Push to Calendar); a token Google reports as `invalid_token` counts as revoked, and any other revoke failure keeps - the connection and its pushed events. + the connection and its pushed events. A connection that requires a + reconnect has no token to revoke and is deleted the same way. - `GET /api/v1/integrations/google/calendars` trades the stored refresh token for an access token and reads every page of the Calendar API's `users/me/calendarList`, answering `[{id, summary, primary, @@ -659,10 +665,27 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. read-only drops out of the listing and cannot be chosen again; the stored choice stays until the household chooses another. - Listing and choosing answer 409 `not_connected` when nothing is - connected, and 502 `reconnect_required` when Google no longer accepts - the grant (`invalid_grant` on refresh, or HTTP 401 from the Calendar - API); the connection is left as it is. Any other Google failure is 502 - `google_failed`. + connected, and fail on Google as every Calendar call does (below). + - **Token failures**, the same for listing, choosing and pushing: + - When Google refuses the grant — `invalid_grant` on the token + refresh, or HTTP 401 from the Calendar API to the access token just + refreshed — the connection becomes reconnect required, under the + account lock: `refresh_token_ct` is cleared and `last_sync_error` + set to "Google access was revoked or expired — reconnect", and the + call answers 409 `google_reconnect_required`. Later calls answer the + same without contacting Google, until a reconnect stores a new token + (and clears `last_sync_error`) or a disconnect deletes the row. The + chosen calendar and every stored `calendar_event_id` are kept: the + pushed events are still on the chosen calendar, so the invariant + below holds, and the first push after reconnecting updates them in + place. + - HTTP 429 and 5xx from the token endpoint or the Calendar API are + retried at most twice (three tries), waiting 0.5 s then 1 s, still + under the account lock. If the last try fails the same way the call + answers 503 `google_unavailable` with `retryable: true`; nothing is + cleared or disconnected. An event insert retried after Google took it + is answered 409 and updated, so a retry never adds a second event. + - Any other Google failure is 502 `google_failed`. - Connect's exchange-and-store, disconnect's read-revoke-delete and every use of the stored token (listing and choosing calendars) hold one lock across the Google requests and the database write: Google revokes @@ -695,8 +718,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. `changed`), so the next push writes the whole week to the calendar then chosen and a push that read such a plan before is refused (409 `plan_changed`). A reconnect while connected keeps the chosen calendar, - so it keeps the event ids. Nothing else clears the choice or the stored - token: a grant Google refuses leaves the connection as it is. + so it keeps the event ids. Nothing else clears the choice: a grant + Google refuses clears only the stored token (see Token failures), so + the event ids stay on the calendar still chosen. - Only a push that wrote all its events is recorded, in one transaction: the plan becomes `synced` with `calendar_synced_at`, and each day written stores its event id. A push that failed part way records @@ -709,8 +733,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar chosen), 409 `calendar_unavailable` (the chosen calendar was deleted — Google 404 — or is no longer writable — 403 `requiredAccessLevel`; - choose another), 502 `reconnect_required` (the grant is refused; the - connection is left as it is), 502 `google_failed`, 404 when the week + choose another), 409 `google_reconnect_required`, 503 + `google_unavailable` (retryable) and 502 `google_failed` (see Token + failures), 404 when the week has no plan, 400 `invalid_week`. The push holds the account lock across its Google requests and its record, like listing and choosing. - The week's Supply run (Feature 17) is made from a pushed plan: a diff --git a/internal/db/querier.go b/internal/db/querier.go index bcfc102..0178e6d 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -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) diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 827055f..a68719b 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -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 diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go index 9ce3436..b516ce0 100644 --- a/internal/gcal/calendars.go +++ b/internal/gcal/calendars.go @@ -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 { @@ -147,7 +160,7 @@ func forgetEvents(ctx context.Context, q *db.Queries, now string) error { // calendars lists the writable calendars, each once, with the stored refresh // token. The caller holds s.account. func (s *Service) calendars(ctx context.Context) ([]Calendar, error) { - row, err := s.connection(ctx) + row, err := s.grantedConnection(ctx) if err != nil { return nil, err } @@ -194,21 +207,54 @@ 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. +// grantedConnection reads the connected account for a Calendar call that +// uses the stored grant: listing, choosing and pushing. It decides the +// precedence of their preconditions: nothing connected is ErrNotConnected, +// then a grant already recorded as refused (no refresh token stored) is +// ErrReconnect, before anything else a call checks, such as whether a +// calendar is chosen. Only connecting again restores a refused grant, so +// it is reported whatever the calendar choice, and the refused token is +// never offered to Google. +func (s *Service) grantedConnection(ctx context.Context) (db.IntegrationsGoogle, error) { + row, err := s.connection(ctx) + if err != nil { + return db.IntegrationsGoogle{}, err + } + if row.RefreshTokenCt == nil { + return db.IntegrationsGoogle{}, ErrReconnect + } + return row, nil +} + +// accessToken trades row's refresh token, from grantedConnection, for an +// access token, retrying a transient failure as send does. A grant Google +// reports as invalid_grant is ErrReconnect. func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) { 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 +265,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 +279,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)} } diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go index fc37080..28aadc7 100644 --- a/internal/gcal/connect.go +++ b/internal/gcal/connect.go @@ -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 { diff --git a/internal/gcal/connect_test.go b/internal/gcal/connect_test.go index 545e445..9072597 100644 --- a/internal/gcal/connect_test.go +++ b/internal/gcal/connect_test.go @@ -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 } diff --git a/internal/gcal/events.go b/internal/gcal/events.go index fa63590..494805f 100644 --- a/internal/gcal/events.go +++ b/internal/gcal/events.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "regexp" @@ -81,10 +80,13 @@ type eventTime struct { // includes an event deleted in Calendar) is updated in place instead, so // writing an event again never adds a second one. It stops at the first // failure; the events before it stay written, and writing them all again -// is safe. Nothing connected is ErrNotConnected, no calendar chosen is -// ErrNoCalendar, a chosen calendar that was deleted or is no longer +// is safe. Nothing connected is ErrNotConnected, a grant already recorded +// as refused is ErrReconnect whatever calendar is chosen, 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,7 +103,15 @@ func (s *Service) PushEvents(ctx context.Context, events []Event, record func() } s.account.Lock() defer s.account.Unlock() - row, err := s.connection(ctx) + return s.recordRefusal(ctx, s.pushEvents(ctx, events, record)) +} + +// pushEvents is PushEvents once the events are valid. The caller holds +// s.account. A refused grant is reported before no calendar chosen +// (grantedConnection): choosing a calendar cannot fix it, connecting again +// can. +func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error { + row, err := s.grantedConnection(ctx) if err != nil { return err } @@ -148,43 +158,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 +196,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 diff --git a/internal/gcal/events_test.go b/internal/gcal/events_test.go index b754419..471acba 100644 --- a/internal/gcal/events_test.go +++ b/internal/gcal/events_test.go @@ -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()) diff --git a/internal/gcal/export_test.go b/internal/gcal/export_test.go index 1bdec15..71b67a1 100644 --- a/internal/gcal/export_test.go +++ b/internal/gcal/export_test.go @@ -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 } diff --git a/internal/gcal/gcaltest/gcaltest.go b/internal/gcal/gcaltest/gcaltest.go index 8a3a51e..78d88af 100644 --- a/internal/gcal/gcaltest/gcaltest.go +++ b/internal/gcal/gcaltest/gcaltest.go @@ -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,32 @@ 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 +} + +// AcceptAccessToken undoes RejectAccessToken, as Google does for the grant +// of a later connection. +func (g *Google) AcceptAccessToken() { + g.mu.Lock() + defer g.mu.Unlock() + g.rejectAccess = false } // CalendarCalls is how many requests reached the calendarList endpoint. @@ -315,9 +345,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 +520,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 +648,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 } diff --git a/internal/gcal/grant.go b/internal/gcal/grant.go new file mode 100644 index 0000000..d704206 --- /dev/null +++ b/internal/gcal/grant.go @@ -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)} + } + } +} diff --git a/internal/gcal/grant_test.go b/internal/gcal/grant_test.go new file mode 100644 index 0000000..3993ee7 --- /dev/null +++ b/internal/gcal/grant_test.go @@ -0,0 +1,336 @@ +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) +} diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go index 5f4d44d..ac570a4 100644 --- a/internal/plan/push_test.go +++ b/internal/plan/push_test.go @@ -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") } diff --git a/internal/server/google.go b/internal/server/google.go index 1ce39ed..9e2d085 100644 --- a/internal/server/google.go +++ b/internal/server/google.go @@ -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) + } +} diff --git a/internal/server/google_test.go b/internal/server/google_test.go index 23823f7..3035ec5 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -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. diff --git a/internal/server/plans.go b/internal/server/plans.go index 5aa29e3..cf81dc6 100644 --- a/internal/server/plans.go +++ b/internal/server/plans.go @@ -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) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 6e61c8d..1b67833 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -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) @@ -456,6 +473,30 @@ func TestPushPlanToCalendarErrors(t *testing.T) { } } +// TestPushPlanAfterRefusalWithNoCalendar pushes after Google refused the +// grant while listing calendars, before any was chosen: the push is 409 +// google_reconnect_required, not no_calendar, since only connecting again +// restores it. After connecting again and choosing a calendar it succeeds. +func TestPushPlanAfterRefusalWithNoCalendar(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.connectCalendar(t, false) + e.google.RevokeGrant() + if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Calendars = %v, want ErrReconnect", err) + } + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusConflict || errorCode(body) != "google_reconnect_required" { + t.Errorf("push = %d %s, want 409 google_reconnect_required", rec.Code, rec.Body) + } + + e.connectCalendar(t, true) + if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusOK { + t.Errorf("push after connecting again = %d: %s", rec.Code, rec.Body) + } +} + func TestPushPlanChangedWhilePushing(t *testing.T) { t.Parallel() e := newPlansRouter(t) diff --git a/internal/server/server.go b/internal/server/server.go index 336d4f0..5de1285 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 5533fdc..4349f60 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -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) + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 510a752..8750a2a 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -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"}, diff --git a/migrations/0015_google_reconnect_required.sql b/migrations/0015_google_reconnect_required.sql new file mode 100644 index 0000000..7b62bb6 --- /dev/null +++ b/migrations/0015_google_reconnect_required.sql @@ -0,0 +1,31 @@ +-- 0015_google_reconnect_required: when Google refuses the stored grant +-- (invalid_grant on refresh, or HTTP 401 from the Calendar API) the +-- connection is kept as reconnect required: its refresh token is cleared +-- and last_sync_error says why. SQLite cannot drop a NOT NULL constraint, +-- so the table is rebuilt with refresh_token_ct nullable. A row without a +-- refresh token always carries the reason. No table references +-- integrations_google. + +CREATE TABLE integrations_google_new ( + id INTEGER PRIMARY KEY CHECK (id = 1), + refresh_token_ct BLOB, + calendar_id TEXT, + calendar_name TEXT, + connected_at TEXT NOT NULL, + last_sync_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (refresh_token_ct IS NOT NULL OR last_sync_error IS NOT NULL) +); + +INSERT INTO integrations_google_new ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) +SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +FROM integrations_google; + +DROP TABLE integrations_google; + +ALTER TABLE integrations_google_new RENAME TO integrations_google; diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index e2b978b..d9983ec 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -112,6 +112,14 @@ UPDATE integrations_google SET calendar_id = ?, calendar_name = ?, updated_at = ? WHERE id = 1; +-- name: ClearIntegrationsGoogleToken :execrows +-- 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. +UPDATE integrations_google +SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ? +WHERE id = 1; + -- name: DeleteIntegrationsGoogle :exec DELETE FROM integrations_google WHERE id = 1;