From 482bec97277797239cfa28240378ab570e4931cf Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Wed, 7 Oct 2026 21:57:46 -0400 Subject: [PATCH] Calendar list and selection (Feature 16) (#126) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 1 + docs/ARCHITECTURE.md | 32 ++- internal/db/querier.go | 2 + internal/db/settings.sql.go | 21 ++ internal/gcal/calendars.go | 223 +++++++++++++++++++++ internal/gcal/calendars_test.go | 302 +++++++++++++++++++++++++++++ internal/gcal/connect.go | 41 ++-- internal/gcal/connect_test.go | 4 +- internal/gcal/gcaltest/gcaltest.go | 220 +++++++++++++++++++-- internal/server/google.go | 71 +++++++ internal/server/google_test.go | 155 ++++++++++++++- internal/server/server.go | 5 +- internal/server/server_test.go | 1 + migrations/queries/settings.sql | 6 + 14 files changed, 1040 insertions(+), 44 deletions(-) create mode 100644 internal/gcal/calendars.go create mode 100644 internal/gcal/calendars_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7a38817..4f984b5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -126,6 +126,7 @@ func run(cfg config.Config, logger *slog.Logger) error { SecretKey: []byte(cfg.SecretKey.Reveal()), Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL, + CalendarAPI: gcal.GoogleCalendarAPI, }) if err != nil { return err diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1635116..10bd452 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -620,8 +620,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. dinner. - `internal/gcal` owns the connection, as a confidential OAuth web client (`MEALQUEST_GOOGLE_CLIENT_ID`, `MEALQUEST_GOOGLE_CLIENT_SECRET_FILE`, both - required at boot) over plain REST — no Google SDK. All three routes are - under the signed-in `/api/v1`: + required at boot) over plain REST — no Google SDK. Every route is under + the signed-in `/api/v1`: - `GET /api/v1/integrations/google/connect` redirects to Google's consent screen with scopes `calendar.events` and `calendar.calendarlist.readonly`, `access_type=offline`, `prompt=consent` and PKCE. Its `state` travels in @@ -641,10 +641,30 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at Google, then deletes the row; a token Google reports as `invalid_token` counts as revoked, and any other revoke failure keeps the connection. - - Connect's exchange-and-store and disconnect's read-revoke-delete hold - one lock across the Google request and the database write: Google - revokes the whole grant, so a revoke overlapping a reconnect would - otherwise leave a stored token Google has already revoked. + - `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, + accessRole}]` for the calendars whose `accessRole` is `writer` or + `owner`, in Google's order. Nothing else from Google's response is + passed on. + - `PUT /api/v1/integrations/google/calendar` `{calendar_id}` + (CSRF-checked) lists the calendars the same way and stores + `calendar_id` and `calendar_name` (the calendar's `summary`) only when + the id is among them, answering `{calendar_id, calendar_name}`; any + other id is 422 `unknown_calendar` and leaves the stored choice as it + was. A calendar chosen earlier that has since been deleted or become + 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`. + - 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 + the whole grant, so a revoke overlapping a reconnect would otherwise + leave a stored token Google has already revoked. - No response, log line or error ever holds a token or Google's response text. - **Deterministic event ids**; each plan day records its diff --git a/internal/db/querier.go b/internal/db/querier.go index 81dc22c..c80d8fe 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -194,6 +194,8 @@ type Querier interface { RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + // Sets the chosen calendar; affects no row when nothing is connected. + SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) // Marks a pending batch running; a batch in any other status is left as // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 05d40d3..827055f 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -139,6 +139,27 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett return i, err } +const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows +UPDATE integrations_google +SET calendar_id = ?, calendar_name = ?, updated_at = ? +WHERE id = 1 +` + +type SetIntegrationsGoogleCalendarParams struct { + CalendarID sql.NullString + CalendarName sql.NullString + UpdatedAt string +} + +// Sets the chosen calendar; affects no row when nothing is connected. +func (q *Queries) SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setIntegrationsGoogleCalendar, arg.CalendarID, arg.CalendarName, arg.UpdatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const upsertAISettings = `-- name: UpsertAISettings :one INSERT INTO ai_settings ( id, provider, base_url, model, temperature, system_prompt, api_key_ct, diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go new file mode 100644 index 0000000..c7c3961 --- /dev/null +++ b/internal/gcal/calendars.go @@ -0,0 +1,223 @@ +package gcal + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "golang.org/x/oauth2" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const ( + // calendarPageSize is the calendarList page size: Google's maximum. + calendarPageSize = 250 + // maxCalendarPages caps how many calendarList pages a listing reads. + maxCalendarPages = 20 + // maxCalendarBody caps how much of one calendarList page is read. + maxCalendarBody = 1 << 20 +) + +var ( + // ErrReconnect means Google no longer accepts the stored grant: the + // household revoked MealQuest's access in their Google account, or the + // refresh token expired. Only connecting again restores it. + ErrReconnect = errors.New("gcal: Google no longer accepts MealQuest's access; connect Google Calendar again") + // ErrUnknownCalendar means the calendar chosen is not one the connected + // account can push to. + ErrUnknownCalendar = errors.New("gcal: not a calendar MealQuest can add dinners to") +) + +// Calendar is one calendar the connected account can push to. +type Calendar struct { + ID string `json:"id"` + Summary string `json:"summary"` + Primary bool `json:"primary"` + AccessRole string `json:"accessRole"` +} + +// Selection is the calendar MealQuest pushes to. +type Selection struct { + CalendarID string `json:"calendar_id"` + CalendarName string `json:"calendar_name"` +} + +// CalendarError is a Calendar API or token refresh request Google refused +// or that failed in transit. Its text never holds a token or Google's +// response body. +type CalendarError struct { + msg string +} + +func (e *CalendarError) Error() string { return e.msg } + +// Calendars lists the connected account's calendars it can push to: those +// it is a writer or owner of, in Google's order. +func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) { + s.account.Lock() + defer s.account.Unlock() + return s.calendars(ctx) +} + +// SelectCalendar makes id the calendar MealQuest pushes to, storing its id +// and name. id must be among Calendars at the time of the call; anything +// 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. +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 + } + for _, c := range cals { + if c.ID != id { + continue + } + sel := Selection{CalendarID: c.ID, CalendarName: c.Summary} + n, err := db.New(s.st.Writer).SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{ + CalendarID: sql.NullString{String: sel.CalendarID, Valid: true}, + CalendarName: sql.NullString{String: sel.CalendarName, Valid: true}, + UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(), + }) + if err != nil { + return Selection{}, fmt.Errorf("gcal: save calendar: %w", err) + } + if n != 1 { + // The lock is held since the connection was read, so only a + // change outside this service gets here. + return Selection{}, ErrNotConnected + } + return sel, nil + } + return Selection{}, ErrUnknownCalendar +} + +// 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) { + access, err := s.accessToken(ctx) + if err != nil { + return nil, err + } + cals := []Calendar{} + // listed holds the ids in cals: pages can overlap, and a calendar is + // listed once, as Google first lists it. + listed := map[string]bool{} + pageToken := "" + for range maxCalendarPages { + page, err := s.calendarPage(ctx, access, pageToken) + if err != nil { + return nil, err + } + for _, item := range page.Items { + if item.ID == "" { + return nil, &CalendarError{msg: "gcal: Google listed a calendar without an id"} + } + if (item.AccessRole == "writer" || item.AccessRole == "owner") && !listed[item.ID] { + listed[item.ID] = true + cals = append(cals, Calendar{ID: item.ID, Summary: item.Summary, Primary: item.Primary, AccessRole: item.AccessRole}) + } + } + if page.NextPageToken == "" { + return cals, nil + } + pageToken = page.NextPageToken + } + return nil, &CalendarError{msg: fmt.Sprintf("gcal: Google listed more than %d pages of calendars", maxCalendarPages)} +} + +// accessToken trades the stored refresh token for an access token. A grant +// Google reports as invalid_grant is ErrReconnect. +func (s *Service) accessToken(ctx context.Context) (string, error) { + row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotConnected + } + if err != nil { + return "", fmt.Errorf("gcal: read Google integration: %w", err) + } + 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 { + 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): + return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)} + case errors.As(err, &transport): + // The URL is dropped: after a refused redirect it is the + // Google-supplied Location. + return "", &CalendarError{msg: fmt.Sprintf("gcal: token refresh failed: %s: %v", transport.Op, transport.Err)} + default: + return "", &CalendarError{msg: "gcal: token refresh returned an unusable response"} + } + } + return token.AccessToken, nil +} + +// calendarListPage is the part of a calendarList page MealQuest reads. +type calendarListPage struct { + Items []struct { + ID string `json:"id"` + Summary string `json:"summary"` + Primary bool `json:"primary"` + AccessRole string `json:"accessRole"` + } `json:"items"` + NextPageToken string `json:"nextPageToken"` +} + +// calendarPage fetches the calendarList page at pageToken ("" for the +// first) with access. HTTP 401 means Google no longer accepts the grant. +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)} + } + return calendarListPage{}, &CalendarError{msg: "gcal: calendar list request failed"} + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxCalendarBody+1)) + if err != nil { + return calendarListPage{}, &CalendarError{msg: "gcal: read calendar list failed"} + } + switch { + case resp.StatusCode == 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 len(body) > maxCalendarBody: + return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)} + } + var page calendarListPage + if err := json.Unmarshal(body, &page); err != nil { + return calendarListPage{}, &CalendarError{msg: "gcal: Google returned an unreadable calendar list"} + } + return page, nil +} diff --git a/internal/gcal/calendars_test.go b/internal/gcal/calendars_test.go new file mode 100644 index 0000000..e2cece6 --- /dev/null +++ b/internal/gcal/calendars_test.go @@ -0,0 +1,302 @@ +package gcal_test + +import ( + "bytes" + "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" +) + +// Calendars the fake lists: two the household can push to, two it cannot, +// and a third writable one with Google fields MealQuest must not pass on. +var ( + ownPrimary = gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"} + family = gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", + Description: "private notes", SummaryOverride: "Fam"} + holidays = gcaltest.Calendar{ID: "en.usa#holiday@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"} + busy = gcaltest.Calendar{ID: "coworker@example.com", Summary: "Coworker", AccessRole: "freeBusyReader"} + dinners = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"} +) + +// connected is a harness with the household's Google account connected +// and the fake listing every calendar above, two to a page. +func connected(t *testing.T) *harness { + t.Helper() + h := newHarness(t) + c := h.connect(t, session) + if _, err := h.callback(c, session, h.consented(c)); err != nil { + t.Fatalf("Complete: %v", err) + } + h.google.SetCalendars(2, ownPrimary, holidays, family, busy, dinners) + return h +} + +func TestCalendarsListsWritableCalendars(t *testing.T) { + t.Parallel() + h := connected(t) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + got, err := json.Marshal(cals) + if err != nil { + t.Fatal(err) + } + // Exactly the writer and owner calendars, in Google's order, with only + // id, summary, primary and accessRole. + want := `[` + + `{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` + + `{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` + + `{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]` + if string(got) != want { + t.Errorf("Calendars =\n%s\nwant\n%s", got, want) + } + if n := h.google.CalendarCalls(); n != 3 { + t.Errorf("calendarList called %d times, want 3 (every page)", n) + } + if _, ok := h.row(t); !ok { + t.Error("listing changed the connection") + } +} + +func TestCalendarsOverlappingPages(t *testing.T) { + t.Parallel() + h := connected(t) + // Two to a page, family ends the first page and starts the second + // again, renamed in between. + renamed := family + renamed.Summary = "Family (renamed)" + h.google.SetCalendars(2, ownPrimary, family, renamed, dinners) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + got, err := json.Marshal(cals) + if err != nil { + t.Fatal(err) + } + // family once, as first listed, in Google's order. + want := `[` + + `{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` + + `{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` + + `{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]` + if string(got) != want { + t.Errorf("Calendars =\n%s\nwant\n%s", got, want) + } + if n := h.google.CalendarCalls(); n != 2 { + t.Errorf("calendarList called %d times, want 2 (every page)", n) + } +} + +func TestCalendarsEmpty(t *testing.T) { + t.Parallel() + h := connected(t) + h.google.SetCalendars(0, holidays) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + if got, _ := json.Marshal(cals); string(got) != "[]" { + t.Errorf("Calendars = %s, want []", got) + } +} + +func TestCalendarsNotConnected(t *testing.T) { + t.Parallel() + h := newHarness(t) + if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrNotConnected) { + t.Errorf("Calendars = %v, want ErrNotConnected", err) + } + if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrNotConnected) { + t.Errorf("SelectCalendar = %v, want ErrNotConnected", err) + } + if h.google.TokenCalls() != 0 || h.google.CalendarCalls() != 0 { + t.Error("Google was called with nothing connected") + } +} + +func TestSelectCalendarStoresIDAndName(t *testing.T) { + t.Parallel() + h := connected(t) + before, _ := h.row(t) + h.clock = h.clock.Add(time.Hour) + + sel, err := h.svc.SelectCalendar(context.Background(), family.ID) + if err != nil { + t.Fatalf("SelectCalendar: %v", err) + } + if sel != (gcal.Selection{CalendarID: family.ID, CalendarName: "Family"}) { + t.Errorf("selection = %+v", sel) + } + row, _ := h.row(t) + if row.CalendarID.String != family.ID || !row.CalendarID.Valid || + row.CalendarName.String != "Family" || !row.CalendarName.Valid { + t.Errorf("stored calendar = %v %v, want %s Family", row.CalendarID, row.CalendarName, family.ID) + } + if row.UpdatedAt != "2026-10-07T19:30:00.000Z" { + t.Errorf("updated_at = %q, want the choice's time", row.UpdatedAt) + } + if !bytes.Equal(row.RefreshTokenCt, before.RefreshTokenCt) || row.ConnectedAt != before.ConnectedAt || row.CreatedAt != before.CreatedAt { + t.Error("choosing a calendar changed the connection") + } + + // Choosing again replaces the choice. + if _, err := h.svc.SelectCalendar(context.Background(), ownPrimary.ID); err != nil { + t.Fatalf("second SelectCalendar: %v", err) + } + if row, _ := h.row(t); row.CalendarID.String != ownPrimary.ID || row.CalendarName.String != ownPrimary.Summary { + t.Errorf("stored calendar = %v %v, want the primary", row.CalendarID, row.CalendarName) + } +} + +func TestSelectCalendarRejectsUnlistedCalendar(t *testing.T) { + t.Parallel() + for name, id := range map[string]string{ + "unknown": "nobody@group.calendar.google.com", + "reader": holidays.ID, + "free/busy reader": busy.ID, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil { + t.Fatal(err) + } + before, _ := h.row(t) + if _, err := h.svc.SelectCalendar(context.Background(), id); !errors.Is(err, gcal.ErrUnknownCalendar) { + t.Fatalf("SelectCalendar(%s) = %v, want ErrUnknownCalendar", id, err) + } + if row, _ := h.row(t); !reflect.DeepEqual(row, before) { + t.Errorf("a refused choice changed the row: %+v", row) + } + }) + } +} + +// TestSelectCalendarNoLongerWritable chooses a calendar, then has Google +// stop listing it as writable, as when it is deleted or the household's +// access drops to reader. It leaves the listing, and choosing it again is +// refused; the stored choice stays until the household chooses another. +func TestSelectCalendarNoLongerWritable(t *testing.T) { + t.Parallel() + for name, now := range map[string][]gcaltest.Calendar{ + "deleted": {ownPrimary}, + "demoted to read": {ownPrimary, {ID: family.ID, Summary: family.Summary, AccessRole: "reader"}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + if _, err := h.svc.SelectCalendar(context.Background(), family.ID); err != nil { + t.Fatal(err) + } + h.google.SetCalendars(0, now...) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(cals) != 1 || cals[0].ID != ownPrimary.ID { + t.Errorf("Calendars = %+v, want only the primary", cals) + } + if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrUnknownCalendar) { + t.Errorf("choosing it again = %v, want ErrUnknownCalendar", err) + } + if row, _ := h.row(t); row.CalendarID.String != family.ID { + t.Errorf("stored calendar = %v, want the earlier choice kept", row.CalendarID) + } + }) + } +} + +// TestCalendarsGrantNoLongerAccepted lists calendars after Google stops +// accepting MealQuest's grant. The failure is ErrReconnect; the connection +// is left as it is. +func TestCalendarsGrantNoLongerAccepted(t *testing.T) { + t.Parallel() + for name, edit := range map[string]func(*gcaltest.Google){ + "refresh refused": (*gcaltest.Google).RevokeGrant, + "calendar list unauthorized": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusUnauthorized) }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + edit(h.google) + _, err := h.svc.Calendars(context.Background()) + if !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Calendars = %v, want ErrReconnect", err) + } + assertNoSecret(t, "error", err.Error()) + _, err = h.svc.SelectCalendar(context.Background(), family.ID) + if !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("SelectCalendar = %v, want ErrReconnect", err) + } + row, ok := h.row(t) + if !ok || row.CalendarID.Valid { + t.Errorf("row = %+v, %v: want the connection kept with no calendar", row, ok) + } + }) + } +} + +func TestCalendarsGoogleFailureIsRedacted(t *testing.T) { + t.Parallel() + for name, edit := range map[string]func(*gcaltest.Google){ + "token endpoint": func(g *gcaltest.Google) { g.FailToken(http.StatusServiceUnavailable) }, + "calendar list": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusForbidden) }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + edit(h.google) + _, err := h.svc.Calendars(context.Background()) + var calendar *gcal.CalendarError + if !errors.As(err, &calendar) { + t.Fatalf("Calendars = %v, want *CalendarError", err) + } + assertNoSecret(t, "error", err.Error()) + }) + } +} + +// TestSelectCalendarHoldsTheAccount holds a calendar choice at Google while +// a disconnect starts: the disconnect waits for the choice, so it never +// revokes the token the choice is using. +func TestSelectCalendarHoldsTheAccount(t *testing.T) { + t.Parallel() + h := connected(t) + h.google.SetCalendars(0, family) + arrived, release := h.google.HoldCalendarList() + defer release() + selected := make(chan error, 1) + go func() { + _, err := h.svc.SelectCalendar(context.Background(), family.ID) + selected <- err + }() + <-arrived + disconnected := make(chan error, 1) + go func() { disconnected <- h.svc.Disconnect(context.Background()) }() + select { + case err := <-disconnected: + t.Fatalf("Disconnect finished (%v) while a calendar choice was at Google", err) + case <-time.After(200 * time.Millisecond): + } + if n := len(h.google.Revoked()); n != 0 { + t.Fatalf("revoked %d tokens while a calendar choice was at Google", n) + } + release() + if err := <-selected; err != nil { + t.Errorf("SelectCalendar: %v", err) + } + if err := <-disconnected; err != nil { + t.Errorf("Disconnect: %v", err) + } + if _, ok := h.row(t); ok { + t.Error("connection stored after the disconnect") + } +} diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go index 63740e6..12e202a 100644 --- a/internal/gcal/connect.go +++ b/internal/gcal/connect.go @@ -47,6 +47,9 @@ var Scopes = []string{ // GoogleRevokeURL is Google's OAuth token revocation endpoint. const GoogleRevokeURL = "https://oauth2.googleapis.com/revoke" +// GoogleCalendarAPI is the base URL of Google's Calendar API v3. +const GoogleCalendarAPI = "https://www.googleapis.com/calendar/v3" + // GoogleEndpoint is Google's OAuth authorization and token endpoint. var GoogleEndpoint = oauth2.Endpoint{ AuthURL: endpoints.Google.AuthURL, @@ -101,9 +104,13 @@ type Config struct { // RevokeURL is Google's revocation endpoint; production uses // GoogleRevokeURL and tests a fake. RevokeURL string + // CalendarAPI is the Calendar API's base URL; production uses + // GoogleCalendarAPI and tests a fake. + CalendarAPI string } -// Service connects and disconnects the household's Google account. +// Service connects and disconnects the household's Google account and +// chooses the calendar MealQuest pushes to. // MealQuest runs as one process, so the service's in-memory lock and pending // states see every request. type Service struct { @@ -111,16 +118,19 @@ type Service struct { box *secretbox.Box oauth oauth2.Config revokeURL string - client *http.Client - cookie auth.FlowCookie + // calendarAPI is the Calendar API's base URL. + calendarAPI string + client *http.Client + cookie auth.FlowCookie // now is the clock; tests replace it. now func() time.Time - // account serializes every change to the connected account — a - // connect's exchange and store, a disconnect's read, revoke and delete — - // across the request to Google and the database write. Google revokes the - // whole grant, so a revoke overlapping a connect would also kill the - // token the connect stores. + // account serializes every use of and change to the connected account — + // a connect's exchange and store, a disconnect's read, revoke and delete, + // a calendar listing's read and Calendar API calls, a calendar choice's + // listing and store — across the requests to Google and the database + // write. Google revokes the whole grant, so a revoke overlapping a + // connect would also kill the token the connect stores. account sync.Mutex // pending holds the state of every connect started and not yet @@ -143,8 +153,8 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro return nil, errors.New("gcal: public URL is required") case len(cfg.SecretKey) == 0: return nil, errors.New("gcal: secret key is required") - case cfg.Endpoint.AuthURL == "", cfg.Endpoint.TokenURL == "", cfg.RevokeURL == "": - return nil, errors.New("gcal: authorization, token and revoke endpoints are required") + case cfg.Endpoint.AuthURL == "", cfg.Endpoint.TokenURL == "", cfg.RevokeURL == "", cfg.CalendarAPI == "": + return nil, errors.New("gcal: authorization, token, revoke and Calendar API endpoints are required") } return &Service{ st: st, @@ -156,11 +166,12 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath, Scopes: slices.Clone(Scopes), }, - revokeURL: cfg.RevokeURL, - client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect}, - cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey), - now: time.Now, - pending: map[string]time.Time{}, + revokeURL: cfg.RevokeURL, + calendarAPI: cfg.CalendarAPI, + client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect}, + cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey), + now: time.Now, + pending: map[string]time.Time{}, }, nil } diff --git a/internal/gcal/connect_test.go b/internal/gcal/connect_test.go index 434078a..545e445 100644 --- a/internal/gcal/connect_test.go +++ b/internal/gcal/connect_test.go @@ -73,6 +73,7 @@ func newHarness(t *testing.T) *harness { SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(), + CalendarAPI: google.CalendarAPI(), }) if err != nil { t.Fatal(err) @@ -535,7 +536,7 @@ func TestNewServiceRequiresConfig(t *testing.T) { pub, _ := url.Parse(publicURL) valid := gcal.Config{ ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"), - Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL, + Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL, CalendarAPI: gcal.GoogleCalendarAPI, } if _, err := gcal.NewService(h.st, h.box, valid); err != nil { t.Fatalf("valid config: %v", err) @@ -547,6 +548,7 @@ func TestNewServiceRequiresConfig(t *testing.T) { "secret key": func(c *gcal.Config) { c.SecretKey = nil }, "token URL": func(c *gcal.Config) { c.Endpoint.TokenURL = "" }, "revoke URL": func(c *gcal.Config) { c.RevokeURL = "" }, + "Calendar API": func(c *gcal.Config) { c.CalendarAPI = "" }, } { cfg := valid edit(&cfg) diff --git a/internal/gcal/gcaltest/gcaltest.go b/internal/gcal/gcaltest/gcaltest.go index 0d621ca..4a46cbf 100644 --- a/internal/gcal/gcaltest/gcaltest.go +++ b/internal/gcal/gcaltest/gcaltest.go @@ -1,18 +1,22 @@ -// Package gcaltest provides a fake Google OAuth server for tests of the -// Google Calendar connection: a token endpoint that checks the client, the -// redirect URI and PKCE and issues a distinct refresh token per exchange, and -// a revoke endpoint that, like Google's, revokes the whole grant: every token -// issued so far. It never touches the network beyond its own httptest -// server. +// Package gcaltest provides a fake Google OAuth server and Calendar API for +// tests of the Google Calendar connection: a token endpoint that checks the +// client, the redirect URI and PKCE, issues a distinct refresh token per +// exchange and trades a live refresh token for an access token; a revoke +// endpoint that, like Google's, revokes the whole grant: every token issued +// so far; and a calendarList endpoint that requires a live grant's access +// token. It never touches the network beyond its own httptest server. package gcaltest import ( "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "net/http" "net/http/httptest" + "net/url" "slices" + "strconv" "strings" "sync" "testing" @@ -24,7 +28,8 @@ import ( // Fixed credentials and tokens the fake issues and expects. The first // exchange issues RefreshToken; exchange n after it issues RefreshToken -// followed by "-n", so every issued refresh token contains RefreshToken. +// followed by "-n", so every issued refresh token contains RefreshToken. Every +// exchange and refresh issues AccessToken. const ( ClientID = "mealquest-test.apps.googleusercontent.com" ClientSecret = "google-client-s3cret-4f1c" @@ -58,9 +63,29 @@ type Google struct { // revoke has invalidated. issued []string dead map[string]bool - // tokenHold and revokeHold, when set, stop the next request to that - // endpoint. - tokenHold, revokeHold *hold + // tokenHold, revokeHold and calendarHold, when set, stop the next + // request to that endpoint. + tokenHold, revokeHold, calendarHold *hold + // calendars is what the calendarList endpoint lists, pageSize entries + // to a page (all of them when 0). + calendars []Calendar + pageSize int + // calendarStatus, when not 200, fails the calendarList endpoint with a + // body that quotes the access token. + calendarStatus int + calendarCalls int +} + +// Calendar is one calendarList entry. The fake answers each with Google's +// other fields too, so tests can check that nothing beyond id, summary, +// primary and accessRole reaches the household. +type Calendar struct { + ID string + Summary string + Primary bool + AccessRole string + Description string + SummaryOverride string } // hold stops one request: arrived is closed when it arrives, and it goes on @@ -74,16 +99,18 @@ type hold struct { func New(t *testing.T, redirectURL string) *Google { t.Helper() g := &Google{ - RedirectURL: redirectURL, - scope: strings.Join(gcal.Scopes, " "), - tokenStatus: http.StatusOK, - revokeStatus: http.StatusOK, - codes: map[string]string{}, - dead: map[string]bool{}, + RedirectURL: redirectURL, + scope: strings.Join(gcal.Scopes, " "), + tokenStatus: http.StatusOK, + revokeStatus: http.StatusOK, + calendarStatus: http.StatusOK, + codes: map[string]string{}, + dead: map[string]bool{}, } mux := http.NewServeMux() mux.HandleFunc("POST /token", g.token) mux.HandleFunc("POST /revoke", g.revoke) + mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList) g.Server = httptest.NewServer(mux) t.Cleanup(g.Server.Close) return g @@ -102,6 +129,9 @@ func (g *Google) Endpoint() oauth2.Endpoint { // RevokeURL is the fake's revocation endpoint. func (g *Google) RevokeURL() string { return g.Server.URL + "/revoke" } +// CalendarAPI is the fake's Calendar API base URL. +func (g *Google) CalendarAPI() string { return g.Server.URL + "/calendar/v3" } + // Authorize plays the consent screen for an authorization request with PKCE // challenge and returns the code Google would send back. func (g *Google) Authorize(challenge string) string { @@ -143,7 +173,51 @@ func (g *Google) AnswerRevoke(status int, body string) { g.revokeStatus, g.revokeBody = status, body } -// TokenCalls is how many requests reached the token endpoint. +// RevokeGrant revokes every token issued so far, as the household removing +// MealQuest's access in their Google account does. +func (g *Google) RevokeGrant() { + g.mu.Lock() + defer g.mu.Unlock() + for _, t := range g.issued { + g.dead[t] = true + } +} + +// SetCalendars makes the calendarList endpoint list cals, pageSize to a +// page (all on one page when 0). +func (g *Google) SetCalendars(pageSize int, cals ...Calendar) { + g.mu.Lock() + defer g.mu.Unlock() + g.calendars, g.pageSize = slices.Clone(cals), pageSize +} + +// FailCalendarList makes the calendarList endpoint answer status with a +// body that quotes the access token. +func (g *Google) FailCalendarList(status int) { + g.mu.Lock() + defer g.mu.Unlock() + g.calendarStatus = status +} + +// CalendarCalls is how many requests reached the calendarList endpoint. +func (g *Google) CalendarCalls() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.calendarCalls +} + +// HoldCalendarList stops the next calendarList request before it is +// answered. It returns a channel closed when the request arrives and the +// function that lets it go on. +func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) { + g.mu.Lock() + defer g.mu.Unlock() + g.calendarHold = newHold() + return g.calendarHold.arrived, g.calendarHold.releaseFunc() +} + +// TokenCalls is how many requests, exchanges and refreshes, reached the +// token endpoint. func (g *Google) TokenCalls() int { g.mu.Lock() defer g.mu.Unlock() @@ -215,6 +289,10 @@ func (g *Google) token(w http.ResponseWriter, r *http.Request) { return } f := r.PostForm + if f.Get("grant_type") == "refresh_token" { + g.refresh(w, f) + return + } challenge, known := g.codes[f.Get("code")] delete(g.codes, f.Get("code")) switch { @@ -251,6 +329,114 @@ func (g *Google) token(w http.ResponseWriter, r *http.Request) { AccessToken, refresh, g.scope) } +// refresh answers a refresh_token grant: an access token for a live refresh +// token, invalid_grant quoting the token for any other. +func (g *Google) refresh(w http.ResponseWriter, f url.Values) { + token := f.Get("refresh_token") + switch { + case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret: + oauthError(w, http.StatusUnauthorized, "invalid_client") + return + case !slices.Contains(g.issued, token) || g.dead[token]: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"Token has been expired or revoked: %s"}`, token) + return + case g.tokenStatus != http.StatusOK: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(g.tokenStatus) + fmt.Fprintf(w, `{"error":"server_error","error_description":"echo %s %s"}`, token, ClientSecret) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":%q,"expires_in":3599,"scope":%q,"token_type":"Bearer"}`, AccessToken, g.scope) +} + +// calendarList answers like Google's calendarList.list: the scripted +// calendars, a page at a time, to a live grant's access token. +func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + defer g.mu.Unlock() + g.calendarCalls++ + h := g.calendarHold + g.calendarHold = nil + g.wait(h) + auth := r.Header.Get("Authorization") + if auth != "Bearer "+AccessToken || !g.grantLive() { + 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 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(g.calendarStatus) + fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, g.calendarStatus, auth) + return + } + start := 0 + if pt := r.URL.Query().Get("pageToken"); pt != "" { + n, err := strconv.Atoi(strings.TrimPrefix(pt, "page-")) + if err != nil || n <= 0 || n > len(g.calendars) { + http.Error(w, "bad pageToken", http.StatusBadRequest) + return + } + start = n + } + end := len(g.calendars) + if g.pageSize > 0 { + end = min(start+g.pageSize, end) + } + items := []map[string]any{} + for _, c := range g.calendars[start:end] { + item := map[string]any{ + "kind": "calendar#calendarListEntry", + "etag": `"1700000000000000"`, + "id": c.ID, + "summary": c.Summary, + "description": c.Description, + "timeZone": "America/New_York", + "colorId": "14", + "backgroundColor": "#9fe1e7", + "foregroundColor": "#000000", + "selected": true, + "accessRole": c.AccessRole, + "defaultReminders": []map[string]any{ + {"method": "popup", "minutes": 30}, + }, + "conferenceProperties": map[string]any{"allowedConferenceSolutionTypes": []string{"hangoutsMeet"}}, + } + if c.Primary { + item["primary"] = true + } + if c.SummaryOverride != "" { + item["summaryOverride"] = c.SummaryOverride + } + items = append(items, item) + } + page := map[string]any{"kind": "calendar#calendarList", "etag": `"p33k"`, "items": items} + if end < len(g.calendars) { + page["nextPageToken"] = fmt.Sprintf("page-%d", end) + } else { + page["nextSyncToken"] = "sync-token" + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(page); err != nil { + panic(err) + } +} + +// grantLive reports whether any refresh token issued is still live. The +// caller holds g.mu. +func (g *Google) grantLive() bool { + for _, t := range g.issued { + if !g.dead[t] { + return true + } + } + return false +} + func (g *Google) revoke(w http.ResponseWriter, r *http.Request) { g.mu.Lock() defer g.mu.Unlock() diff --git a/internal/server/google.go b/internal/server/google.go index 4b8fc77..1ce39ed 100644 --- a/internal/server/google.go +++ b/internal/server/google.go @@ -100,3 +100,74 @@ func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { } } } + +// maxGoogleCalendarBody bounds a PUT /integrations/google/calendar body. +const maxGoogleCalendarBody = 4 << 10 + +// googleCalendarChoice is the PUT /integrations/google/calendar body. +type googleCalendarChoice struct { + CalendarID *string `json:"calendar_id"` +} + +// getGoogleCalendars lists the connected account's calendars MealQuest can +// push to, as [{id, summary, primary, accessRole}]. +func getGoogleCalendars(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + cals, err := svc.Calendars(r.Context()) + if err != nil { + writeGoogleCalendarError(w, r, logger, "list Google calendars", err) + return + } + writeJSON(w, r, logger, http.StatusOK, cals) + } +} + +// putGoogleCalendar chooses the calendar MealQuest pushes to and answers +// {calendar_id, calendar_name}. +func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[googleCalendarChoice](http.MaxBytesReader(w, r.Body, maxGoogleCalendarBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.CalendarID == nil || *body.CalendarID == "" { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "calendar_id: required") + return + } + sel, err := svc.SelectCalendar(r.Context(), *body.CalendarID) + if err != nil { + writeGoogleCalendarError(w, r, logger, "choose Google calendar", err) + return + } + writeJSON(w, r, logger, http.StatusOK, sel) + } +} + +// 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. +func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var calendar *gcal.CalendarError + switch { + case errors.Is(err, gcal.ErrNotConnected): + writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected") + 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") + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/google_test.go b/internal/server/google_test.go index c954235..23823f7 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -23,9 +23,11 @@ import ( ) const ( - googleConnectPath = "/api/v1/integrations/google/connect" - googleCallbackPath = "/api/v1/integrations/google/callback" - googlePath = "/api/v1/integrations/google" + googleConnectPath = "/api/v1/integrations/google/connect" + googleCallbackPath = "/api/v1/integrations/google/callback" + googlePath = "/api/v1/integrations/google" + googleCalendarsPath = "/api/v1/integrations/google/calendars" + googleCalendarPath = "/api/v1/integrations/google/calendar" ) // googleEnv is a router whose Google Calendar service talks to a fake @@ -60,6 +62,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { SecretKey: []byte("server-secret-key"), Endpoint: e.google.Endpoint(), RevokeURL: e.google.RevokeURL(), + CalendarAPI: e.google.CalendarAPI(), }) if err != nil { t.Fatal(err) @@ -295,13 +298,157 @@ func TestGoogleRoutesRequireSession(t *testing.T) { {http.MethodGet, googleConnectPath}, {http.MethodGet, googleCallbackPath + "?state=s&code=c"}, {http.MethodDelete, googlePath}, + {http.MethodGet, googleCalendarsPath}, + {http.MethodPut, googleCalendarPath}, } { rec := e.serve(httptest.NewRequest(c.method, c.path, nil)) if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON { t.Errorf("%s %s signed out = %d %s, want 401", c.method, c.path, rec.Code, rec.Body) } } - if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 { + if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 || e.google.CalendarCalls() != 0 { t.Error("a signed-out request reached Google") } } + +// connected connects the test session's Google account and has the fake +// list an owned primary calendar, a writable shared one and a read-only one. +func (e *googleEnv) connected(t *testing.T) { + t.Helper() + consent, cookie := e.connect(t) + if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther { + t.Fatalf("callback = %d %s", rec.Code, rec.Body) + } + e.google.SetCalendars(0, + gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"}, + gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", Description: "private notes"}, + gcaltest.Calendar{ID: "holidays@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"}, + ) +} + +func chooseCalendar(id string) *http.Request { + return signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"`+id+`"}`)) +} + +func TestGoogleCalendarsListAndChoose(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + + rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil)) + want := `[{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` + + `{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"}]` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("GET calendars = %d %s, want 200 %s", rec.Code, rec.Body, want) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", cc) + } + + // PUT needs the CSRF header like every other write. + req := httptest.NewRequest(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"family@group.calendar.google.com"}`)) + req.AddCookie(validSessionCookie) + if rec := e.serve(req); rec.Code != http.StatusForbidden { + t.Errorf("PUT without CSRF = %d, want 403", rec.Code) + } + if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.Valid { + t.Fatal("a PUT without CSRF chose a calendar") + } + + rec = e.serve(chooseCalendar("family@group.calendar.google.com")) + if want := `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Family"}`; rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("PUT calendar = %d %s, want 200 %s", rec.Code, rec.Body, want) + } + row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()) + if err != nil { + t.Fatal(err) + } + if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" { + t.Errorf("stored calendar = %v %v", row.CalendarID, row.CalendarName) + } + + for _, id := range []string{"holidays@group.v.calendar.google.com", "nobody@group.calendar.google.com"} { + rec := e.serve(chooseCalendar(id)) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusUnprocessableEntity || code != "unknown_calendar" { + t.Errorf("PUT %s = %d %s, want 422 unknown_calendar", id, rec.Code, rec.Body) + } + } + if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.String != "family@group.calendar.google.com" { + t.Errorf("a refused choice replaced the stored calendar with %v", row.CalendarID) + } +} + +func TestGoogleCalendarChoiceBadRequest(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + calls := e.google.CalendarCalls() + for _, body := range []string{``, `{}`, `{"calendar_id":""}`, `{"calendar_id":null}`, `{"calendar_id":7}`, + `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Mine"}`} { + rec := e.serve(signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(body))) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_request" { + t.Errorf("PUT %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body) + } + } + if e.google.CalendarCalls() != calls { + t.Error("a bad request reached Google") + } +} + +func TestGoogleCalendarsNotConnected(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + 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.StatusConflict || code != "not_connected" || message != "Google Calendar is not connected" { + t.Errorf("%s %s = %d %s, want 409 not_connected", req.Method, req.URL.Path, rec.Code, rec.Body) + } + } + if e.google.TokenCalls() != 0 || e.google.CalendarCalls() != 0 { + t.Error("Google was called with nothing connected") + } +} + +// 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. +func TestGoogleCalendarsGrantRevoked(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) + } + } + if !e.stored(t) { + t.Error("the connection was cleared") + } + // newGoogleEnv's cleanup checks the responses and logs hold no secret. +} + +func TestGoogleCalendarsFailureIsRedacted(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + e.google.FailCalendarList(http.StatusInternalServerError) + 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") { + 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/server.go b/internal/server/server.go index 8a17ce5..d6ba037 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -47,7 +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 and /callback and DELETE +// /api/v1/integrations/google/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 @@ -140,6 +141,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc)) apiV1.Get("/integrations/google/callback", getGoogleCallback(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)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d588bc3..38c8a3e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -236,6 +236,7 @@ func newTestGoogle(t *testing.T) *gcal.Service { SecretKey: []byte("server-secret-key"), Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"}, RevokeURL: "http://127.0.0.1:1/revoke", + CalendarAPI: "http://127.0.0.1:1/calendar/v3", }) if err != nil { t.Fatal(err) diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index 4ff78ee..e2b978b 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -106,6 +106,12 @@ ON CONFLICT (id) DO UPDATE SET updated_at = excluded.updated_at RETURNING *; +-- name: SetIntegrationsGoogleCalendar :execrows +-- Sets the chosen calendar; affects no row when nothing is connected. +UPDATE integrations_google +SET calendar_id = ?, calendar_name = ?, updated_at = ? +WHERE id = 1; + -- name: DeleteIntegrationsGoogle :exec DELETE FROM integrations_google WHERE id = 1;