Handle Google token failures and report Google Calendar status (Feature 16) (#128)
CI / ci (push) Successful in 4m27s
CI / ci (push) Successful in 4m27s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #128.
This commit is contained in:
+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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user