Calendar list and selection (Feature 16) #126
@@ -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
|
||||
|
||||
+26
-6
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+26
-15
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user