Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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
|
||||
// retryable is set when Google answered every try with a transient
|
||||
// failure (HTTP 429 or 5xx).
|
||||
retryable bool
|
||||
}
|
||||
|
||||
func (e *CalendarError) Error() string { return e.msg }
|
||||
|
||||
// Retryable reports whether Google was only briefly unable to answer, so
|
||||
// trying again later may succeed. Nothing was disconnected.
|
||||
func (e *CalendarError) Retryable() bool { return e.retryable }
|
||||
|
||||
// Calendars lists the connected account's calendars it can push to: those
|
||||
// it is a writer or owner of, in Google's order. A grant Google refuses is
|
||||
// ErrReconnect, recorded as reconnect required (recordRefusal).
|
||||
func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
cals, err := s.calendars(ctx)
|
||||
if err != nil {
|
||||
return nil, s.recordRefusal(ctx, err)
|
||||
}
|
||||
return cals, nil
|
||||
}
|
||||
|
||||
// SelectCalendar makes id the calendar MealQuest pushes to, storing its id
|
||||
// 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. Choosing another calendar than the one chosen returns
|
||||
// every plan pushed to that one to not synced; see saveSelection. A grant
|
||||
// Google refuses is ErrReconnect, recorded as reconnect required
|
||||
// (recordRefusal).
|
||||
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
cals, err := s.calendars(ctx)
|
||||
if err != nil {
|
||||
return Selection{}, s.recordRefusal(ctx, err)
|
||||
}
|
||||
for _, c := range cals {
|
||||
if c.ID != id {
|
||||
continue
|
||||
}
|
||||
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
|
||||
if err := s.saveSelection(ctx, sel); err != nil {
|
||||
return Selection{}, err
|
||||
}
|
||||
return sel, nil
|
||||
}
|
||||
return Selection{}, ErrUnknownCalendar
|
||||
}
|
||||
|
||||
// saveSelection stores sel as the chosen calendar. Choosing another calendar
|
||||
// than the one stored also forgets every plan day's calendar event id, in
|
||||
// the same transaction, since those events are on the calendar no longer
|
||||
// chosen (forgetEvents): each plan with one is edited, so a synced plan
|
||||
// becomes changed and its next push writes every day to sel. The caller
|
||||
// holds s.account, which a push also holds from its event writes through
|
||||
// recording them, so a push never records events written to another
|
||||
// calendar than the chosen one.
|
||||
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetIntegrationsGoogle(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// The lock is held since the connection was read, so only a
|
||||
// change outside this service gets here.
|
||||
return ErrNotConnected
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: read Google integration: %w", err)
|
||||
}
|
||||
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
|
||||
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
|
||||
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("gcal: save calendar: %w", err)
|
||||
}
|
||||
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
|
||||
return nil
|
||||
}
|
||||
return forgetEvents(ctx, q, now)
|
||||
})
|
||||
}
|
||||
|
||||
// forgetEvents records, in the caller's transaction q, that no calendar
|
||||
// event id stored is on the chosen calendar any more: every plan with one is
|
||||
// edited (a synced plan becomes changed, and a push that read it before is
|
||||
// refused), then every id is cleared. Every change that ends the chosen
|
||||
// calendar — choosing another (saveSelection) or disconnecting
|
||||
// (Disconnect) — runs it in the transaction that makes the change.
|
||||
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
|
||||
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
|
||||
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
|
||||
}
|
||||
if err := q.ClearCalendarEvents(ctx, now); err != nil {
|
||||
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// calendars lists the writable calendars, each once, with the stored refresh
|
||||
// token. The caller holds s.account.
|
||||
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
|
||||
row, err := s.grantedConnection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
access, err := s.accessToken(ctx, row)
|
||||
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)}
|
||||
}
|
||||
|
||||
// connection reads the connected account, or ErrNotConnected.
|
||||
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
|
||||
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.IntegrationsGoogle{}, ErrNotConnected
|
||||
}
|
||||
if err != nil {
|
||||
return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// grantedConnection reads the connected account for a Calendar call that
|
||||
// uses the stored grant: listing, choosing and pushing. It decides the
|
||||
// precedence of their preconditions: nothing connected is ErrNotConnected,
|
||||
// then a grant already recorded as refused (no refresh token stored) is
|
||||
// ErrReconnect, before anything else a call checks, such as whether a
|
||||
// calendar is chosen. Only connecting again restores a refused grant, so
|
||||
// it is reported whatever the calendar choice, and the refused token is
|
||||
// never offered to Google.
|
||||
func (s *Service) grantedConnection(ctx context.Context) (db.IntegrationsGoogle, error) {
|
||||
row, err := s.connection(ctx)
|
||||
if err != nil {
|
||||
return db.IntegrationsGoogle{}, err
|
||||
}
|
||||
if row.RefreshTokenCt == nil {
|
||||
return db.IntegrationsGoogle{}, ErrReconnect
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// accessToken trades row's refresh token, from grantedConnection, for an
|
||||
// access token, retrying a transient failure as send does. A grant Google
|
||||
// reports as invalid_grant is ErrReconnect.
|
||||
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
|
||||
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gcal: open refresh token: %w", err)
|
||||
}
|
||||
for attempt := 0; ; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := s.backoff(ctx, attempt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
|
||||
&oauth2.Token{RefreshToken: string(refresh)}).Token()
|
||||
if err == nil {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
var retrieve *oauth2.RetrieveError
|
||||
var transport *url.Error
|
||||
switch {
|
||||
case errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant":
|
||||
return "", ErrReconnect
|
||||
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode) && attempt < maxRetries:
|
||||
continue
|
||||
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode):
|
||||
return "", &CalendarError{retryable: true,
|
||||
msg: fmt.Sprintf("gcal: Google could not take the token refresh (HTTP %d) in %d tries", retrieve.Response.StatusCode, maxRetries+1)}
|
||||
case errors.As(err, &retrieve):
|
||||
return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)}
|
||||
case errors.As(err, &transport):
|
||||
// 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
// the access token was just refreshed from it.
|
||||
func (s *Service) calendarPage(ctx context.Context, access, pageToken string) (calendarListPage, error) {
|
||||
q := url.Values{"maxResults": {fmt.Sprint(calendarPageSize)}}
|
||||
if pageToken != "" {
|
||||
q.Set("pageToken", pageToken)
|
||||
}
|
||||
status, body, err := s.send(ctx, "calendar list", maxCalendarBody+1, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return calendarListPage{}, err
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusUnauthorized:
|
||||
return calendarListPage{}, ErrReconnect
|
||||
case status != http.StatusOK:
|
||||
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", status)}
|
||||
case len(body) > maxCalendarBody:
|
||||
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Package gcal connects the household's Google account for Push to Calendar.
|
||||
// The service is a confidential OAuth web client: the authorization code,
|
||||
// tokens and client secret stay on the server. The refresh token is stored
|
||||
// sealed by secretbox in integrations_google and is never logged or
|
||||
// returned.
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// CallbackPath is where Google returns the browser after consent. It is
|
||||
// served under the signed-in /api/v1 router, so the redirect URI registered
|
||||
// with Google is MEALQUEST_PUBLIC_URL's origin followed by this path.
|
||||
const CallbackPath = "/api/v1/integrations/google/callback"
|
||||
|
||||
// Scopes requested at connect: create and change the events MealQuest pushes,
|
||||
// and list the calendars the household can push to.
|
||||
var Scopes = []string{
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
||||
}
|
||||
|
||||
// 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,
|
||||
TokenURL: endpoints.Google.TokenURL,
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
}
|
||||
|
||||
const (
|
||||
connectCookieName = "__Host-mealquest_google"
|
||||
connectCookieTTL = 10 * time.Minute
|
||||
// connectCookieKeyLabel separates the connect cookie's signing key from
|
||||
// the login cookie's and every other key derived from the server secret.
|
||||
connectCookieKeyLabel = "mealquest google connect cookie v1"
|
||||
// googleTimeout bounds every request to Google.
|
||||
googleTimeout = 10 * time.Second
|
||||
// maxRevokeBody caps how much of a revoke response is read.
|
||||
maxRevokeBody = 4 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrBadState means the callback's state is missing, does not match the
|
||||
// connect cookie, or the cookie is missing, expired or belongs to another
|
||||
// session.
|
||||
ErrBadState = errors.New("gcal: connect state is missing, expired or does not match this session")
|
||||
// ErrDenied means Google returned an authorization error, usually because
|
||||
// the household declined consent.
|
||||
ErrDenied = errors.New("gcal: Google did not grant access")
|
||||
// ErrNoCode means the callback carries no authorization code.
|
||||
ErrNoCode = errors.New("gcal: callback has no authorization code")
|
||||
// ErrNoRefreshToken means Google granted access without a refresh token,
|
||||
// so MealQuest could not push later; the household must connect again.
|
||||
ErrNoRefreshToken = errors.New("gcal: Google returned no refresh token")
|
||||
// ErrScopes means the household did not grant every scope in Scopes.
|
||||
ErrScopes = errors.New("gcal: Google did not grant every Calendar permission")
|
||||
// ErrNotConnected means no Google account is connected.
|
||||
ErrNotConnected = errors.New("gcal: Google Calendar is not connected")
|
||||
)
|
||||
|
||||
// Config is what NewService needs to talk to Google.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// PublicURL is the service's public URL; its origin prefixes CallbackPath
|
||||
// to form the redirect URI.
|
||||
PublicURL *url.URL
|
||||
// SecretKey is the server key the connect cookie's signing key derives
|
||||
// from.
|
||||
SecretKey []byte
|
||||
// Endpoint is Google's authorization and token endpoint; production uses
|
||||
// GoogleEndpoint and tests a fake.
|
||||
Endpoint oauth2.Endpoint
|
||||
// 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,
|
||||
// chooses the calendar MealQuest pushes to and writes events to it.
|
||||
// MealQuest runs as one process, so the service's in-memory lock and pending
|
||||
// states see every request.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
oauth oauth2.Config
|
||||
revokeURL string
|
||||
// calendarAPI is the Calendar API's base URL.
|
||||
calendarAPI string
|
||||
client *http.Client
|
||||
cookie auth.FlowCookie
|
||||
// retryDelay is the wait before the first retry of a transient
|
||||
// failure; tests shorten it.
|
||||
retryDelay time.Duration
|
||||
// now is the clock; tests replace it.
|
||||
now func() time.Time
|
||||
|
||||
// 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, an event push's read, event writes and record —
|
||||
// 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
|
||||
// completed, with when it expires. Complete consumes a state once, so a
|
||||
// replayed cookie finds it gone.
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing refresh tokens with box.
|
||||
func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("gcal: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("gcal: nil secret box")
|
||||
case cfg.ClientID == "", cfg.ClientSecret == "":
|
||||
return nil, errors.New("gcal: client ID and client secret are required")
|
||||
case cfg.PublicURL == nil:
|
||||
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 == "", cfg.CalendarAPI == "":
|
||||
return nil, errors.New("gcal: authorization, token, revoke and Calendar API endpoints are required")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
box: box,
|
||||
oauth: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: cfg.Endpoint,
|
||||
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
|
||||
Scopes: slices.Clone(Scopes),
|
||||
},
|
||||
revokeURL: cfg.RevokeURL,
|
||||
calendarAPI: cfg.CalendarAPI,
|
||||
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
|
||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||
retryDelay: firstRetryDelay,
|
||||
now: time.Now,
|
||||
pending: map[string]time.Time{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// connectState travels from Connect to Complete in the signed connect
|
||||
// cookie.
|
||||
type connectState struct {
|
||||
State string `json:"s"`
|
||||
Verifier string `json:"v"`
|
||||
// Session binds the state to the session that started the connect: the
|
||||
// SHA-256 of its CSRF token, which is unique to the session and never
|
||||
// changes during it.
|
||||
Session string `json:"b"`
|
||||
auth.FlowExpiry
|
||||
}
|
||||
|
||||
// sessionBinding is the value connectState.Session must hold for s.
|
||||
func sessionBinding(s auth.Session) string {
|
||||
sum := sha256.Sum256([]byte(s.CSRFToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Connect sets the connect cookie on w, bound to session, and returns
|
||||
// Google's consent URL: offline access, consent always prompted so Google
|
||||
// always issues a refresh token, and PKCE (S256).
|
||||
func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string, error) {
|
||||
if session.CSRFToken == "" {
|
||||
return "", errors.New("gcal: connect needs a session")
|
||||
}
|
||||
state := connectState{
|
||||
State: rand.Text(),
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Session: sessionBinding(session),
|
||||
}
|
||||
now := s.now()
|
||||
if err := s.cookie.Set(w, &state, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
||||
}
|
||||
s.addPending(state.State, now)
|
||||
return s.oauth.AuthCodeURL(state.State,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.ApprovalForce,
|
||||
oauth2.S256ChallengeOption(state.Verifier),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Complete finishes the connect Google redirected r back with. It clears the
|
||||
// connect cookie, checks the state against it and session, consumes the
|
||||
// state, exchanges the code and stores the refresh token, sealed, with
|
||||
// connected_at now. A reconnect replaces the stored token and keeps the
|
||||
// chosen calendar.
|
||||
func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.Session) error {
|
||||
ctx := r.Context()
|
||||
s.cookie.Clear(w)
|
||||
|
||||
var state connectState
|
||||
if err := s.cookie.Read(r, &state, s.now()); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrBadState, err)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
|
||||
return fmt.Errorf("%w: state mismatch", ErrBadState)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(sessionBinding(session)), []byte(state.Session)) != 1 {
|
||||
return fmt.Errorf("%w: started by another session", ErrBadState)
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !s.consumePending(state.State) {
|
||||
return fmt.Errorf("%w: already used", ErrBadState)
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
// error and error_description are Google-supplied text; neither is
|
||||
// kept.
|
||||
return ErrDenied
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
return ErrNoCode
|
||||
}
|
||||
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
token, err := s.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, s.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: %w", auth.ExchangeError(err))
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
return ErrNoRefreshToken
|
||||
}
|
||||
if err := checkScopes(token); err != nil {
|
||||
return err
|
||||
}
|
||||
sealed, err := s.box.Encrypt([]byte(token.RefreshToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: seal refresh token: %w", err)
|
||||
}
|
||||
return s.store(ctx, sealed)
|
||||
}
|
||||
|
||||
// addPending records state, started at now, as pending until the connect
|
||||
// cookie expires, and forgets expired ones.
|
||||
func (s *Service) addPending(state string, now time.Time) {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
for k, expires := range s.pending {
|
||||
if !now.Before(expires) {
|
||||
delete(s.pending, k)
|
||||
}
|
||||
}
|
||||
s.pending[state] = now.Add(connectCookieTTL)
|
||||
}
|
||||
|
||||
// consumePending removes state from the pending states, reporting whether
|
||||
// it was there.
|
||||
func (s *Service) consumePending(state string) bool {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
_, ok := s.pending[state]
|
||||
delete(s.pending, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkScopes fails with ErrScopes unless token's granted scopes include
|
||||
// every scope in Scopes. Google lets the household untick a permission on
|
||||
// the consent screen and reports what was granted in "scope".
|
||||
func checkScopes(token *oauth2.Token) error {
|
||||
granted, _ := token.Extra("scope").(string)
|
||||
have := strings.Fields(granted)
|
||||
for _, want := range Scopes {
|
||||
if !slices.Contains(have, want) {
|
||||
return ErrScopes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// store saves sealed as the refresh token with connected_at now, clearing
|
||||
// any earlier sync error.
|
||||
func (s *Service) store(ctx context.Context, sealed []byte) error {
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetIntegrationsGoogle(ctx)
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("gcal: read Google integration: %w", err)
|
||||
}
|
||||
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
created := now
|
||||
if exists {
|
||||
created = row.CreatedAt
|
||||
}
|
||||
if _, err := q.UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: sealed,
|
||||
CalendarID: row.CalendarID,
|
||||
CalendarName: row.CalendarName,
|
||||
ConnectedAt: now,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("gcal: save Google integration: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect revokes the stored refresh token at Google, then deletes the
|
||||
// connection. A token Google no longer knows (the household already removed
|
||||
// MealQuest's access in their Google account) counts as revoked. Any other
|
||||
// revoke failure keeps the connection so the household can retry.
|
||||
//
|
||||
// A connection whose grant Google already refused (reconnect required, no
|
||||
// refresh token stored) has nothing to revoke and is deleted.
|
||||
//
|
||||
// No calendar is chosen once disconnected, so the same transaction that
|
||||
// deletes the connection forgets every pushed event (forgetEvents): every
|
||||
// synced plan becomes changed, and the next push after connecting again and
|
||||
// choosing a calendar writes the whole week.
|
||||
func (s *Service) Disconnect(ctx context.Context) error {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
row, err := s.connection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.RefreshTokenCt != nil {
|
||||
token, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: open refresh token: %w", err)
|
||||
}
|
||||
if err := s.revoke(ctx, string(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
if err := q.DeleteIntegrationsGoogle(ctx); err != nil {
|
||||
return fmt.Errorf("gcal: delete Google integration: %w", err)
|
||||
}
|
||||
return forgetEvents(ctx, q, store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String())
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeError is a revoke Google refused or that failed in transit. Its text
|
||||
// never holds the token or Google's response body.
|
||||
type RevokeError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *RevokeError) Error() string { return e.msg }
|
||||
|
||||
// revoke revokes token at Google. HTTP 200 and HTTP 400 invalid_token (the
|
||||
// token is already revoked or expired) both leave it unusable.
|
||||
func (s *Service) revoke(ctx context.Context, token string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.revokeURL,
|
||||
strings.NewReader(url.Values{"token": {token}}.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: build revoke request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
// The URL is dropped: after a refused redirect it is the
|
||||
// Google-supplied Location.
|
||||
return &RevokeError{msg: fmt.Sprintf("gcal: revoke request failed: %s: %v", transport.Op, transport.Err)}
|
||||
}
|
||||
return &RevokeError{msg: "gcal: revoke request failed"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRevokeBody))
|
||||
if err != nil {
|
||||
return &RevokeError{msg: "gcal: read revoke response failed"}
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var reply struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &reply) == nil && reply.Error == "invalid_token" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &RevokeError{msg: fmt.Sprintf("gcal: Google refused the revoke (HTTP %d)", resp.StatusCode)}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
package gcal_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const publicURL = "https://meals.example.com/app"
|
||||
|
||||
var (
|
||||
redirectURL = "https://meals.example.com" + gcal.CallbackPath
|
||||
session = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-one"}
|
||||
other = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-two"}
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
svc *gcal.Service
|
||||
google *gcaltest.Google
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
google := gcaltest.New(t, redirectURL)
|
||||
svc, err := gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID,
|
||||
ClientSecret: gcaltest.ClientSecret,
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: google.Endpoint(),
|
||||
RevokeURL: google.RevokeURL(),
|
||||
CalendarAPI: google.CalendarAPI(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
|
||||
gcal.SetClock(svc, func() time.Time { return h.clock })
|
||||
gcal.SetRetryDelay(svc, 0)
|
||||
return h
|
||||
}
|
||||
|
||||
// connect is a started connect: Google's consent URL and the connect
|
||||
// cookie.
|
||||
type connect struct {
|
||||
consent *url.URL
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func (h *harness) connect(t *testing.T, s auth.Session) connect {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
raw, err := h.svc.Connect(rec, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consent, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("Connect set %d cookies, want 1", len(cookies))
|
||||
}
|
||||
return connect{consent: consent, cookie: cookies[0]}
|
||||
}
|
||||
|
||||
// callback completes c as session s with query, returning the error and the
|
||||
// recorder Complete wrote to.
|
||||
func (h *harness) callback(c connect, s auth.Session, query url.Values) (*httptest.ResponseRecorder, error) {
|
||||
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+query.Encode(), nil)
|
||||
if c.cookie != nil {
|
||||
req.AddCookie(c.cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
return rec, h.svc.Complete(rec, req, s)
|
||||
}
|
||||
|
||||
// consented is the query Google sends back after consent to c.
|
||||
func (h *harness) consented(c connect) url.Values {
|
||||
q := c.consent.Query()
|
||||
return url.Values{"state": {q.Get("state")}, "code": {h.google.Authorize(q.Get("code_challenge"))}}
|
||||
}
|
||||
|
||||
func (h *harness) row(t *testing.T) (db.IntegrationsGoogle, bool) {
|
||||
t.Helper()
|
||||
row, err := db.New(h.st.Reader).GetIntegrationsGoogle(context.Background())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.IntegrationsGoogle{}, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row, true
|
||||
}
|
||||
|
||||
func TestConnectRedirectsToConsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
|
||||
if got, want := c.consent.Scheme+"://"+c.consent.Host+c.consent.Path, h.google.Endpoint().AuthURL; got != want {
|
||||
t.Errorf("consent URL = %s, want %s", got, want)
|
||||
}
|
||||
q := c.consent.Query()
|
||||
for name, want := range map[string]string{
|
||||
"client_id": gcaltest.ClientID,
|
||||
"redirect_uri": redirectURL,
|
||||
"response_type": "code",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"code_challenge_method": "S256",
|
||||
} {
|
||||
if got := q.Get(name); got != want {
|
||||
t.Errorf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Fields(q.Get("scope")); !slices.Equal(got, []string{
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
||||
}) {
|
||||
t.Errorf("scope = %q", q.Get("scope"))
|
||||
}
|
||||
if q.Get("state") == "" || q.Get("code_challenge") == "" {
|
||||
t.Errorf("state %q, code_challenge %q: want both set", q.Get("state"), q.Get("code_challenge"))
|
||||
}
|
||||
if q.Get("client_secret") != "" || strings.Contains(c.consent.String(), gcaltest.ClientSecret) {
|
||||
t.Error("consent URL carries the client secret")
|
||||
}
|
||||
|
||||
ck := c.cookie
|
||||
if ck.Name != "__Host-mealquest_google" || ck.Path != "/" || !ck.Secure || !ck.HttpOnly ||
|
||||
ck.SameSite != http.SameSiteLaxMode || ck.MaxAge != 600 || ck.Domain != "" {
|
||||
t.Errorf("connect cookie = %+v, want __Host-, Path=/, Secure, HttpOnly, Lax, 10 minutes", ck)
|
||||
}
|
||||
// Two connects never share a state.
|
||||
if again := h.connect(t, session); again.consent.Query().Get("state") == q.Get("state") {
|
||||
t.Error("two connects share a state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteStoresSealedRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
rec, err := h.callback(c, session, h.consented(c))
|
||||
if err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
row, ok := h.row(t)
|
||||
if !ok {
|
||||
t.Fatal("no integration stored")
|
||||
}
|
||||
if bytes.Contains(row.RefreshTokenCt, []byte(gcaltest.RefreshToken)) {
|
||||
t.Error("refresh token stored in plain text")
|
||||
}
|
||||
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("stored token opens to %q, %v; want the refresh token", plain, err)
|
||||
}
|
||||
if want := "2026-10-07T18:30:00.000Z"; row.ConnectedAt != want || row.CreatedAt != want || row.UpdatedAt != want {
|
||||
t.Errorf("connected_at %q created_at %q updated_at %q, want %s", row.ConnectedAt, row.CreatedAt, row.UpdatedAt, want)
|
||||
}
|
||||
if row.CalendarID.Valid || row.CalendarName.Valid || row.LastSyncError.Valid {
|
||||
t.Errorf("new connection has calendar or sync error: %+v", row)
|
||||
}
|
||||
assertCookieCleared(t, rec)
|
||||
assertNoSecret(t, "response", rec.Body.String()+headerText(rec.Header()))
|
||||
}
|
||||
|
||||
func TestReconnectReplacesTokenAndKeepsCalendar(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
if _, err := db.New(h.st.Writer).UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: []byte("old sealed token"),
|
||||
CalendarID: sql.NullString{String: "family@group.calendar.google.com", Valid: true},
|
||||
CalendarName: sql.NullString{String: "Family", Valid: true},
|
||||
ConnectedAt: "2026-09-01T00:00:00.000Z",
|
||||
LastSyncError: sql.NullString{String: "token revoked", Valid: true},
|
||||
CreatedAt: "2026-09-01T00:00:00.000Z",
|
||||
UpdatedAt: "2026-09-01T00:00:00.000Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
row, _ := h.row(t)
|
||||
if plain, err := h.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("token after reconnect opens to %q, %v", plain, err)
|
||||
}
|
||||
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
|
||||
t.Errorf("calendar after reconnect = %v %v, want it kept", row.CalendarID, row.CalendarName)
|
||||
}
|
||||
if row.LastSyncError.Valid {
|
||||
t.Errorf("last_sync_error = %q, want it cleared", row.LastSyncError.String)
|
||||
}
|
||||
if row.CreatedAt != "2026-09-01T00:00:00.000Z" || row.ConnectedAt != "2026-10-07T18:30:00.000Z" {
|
||||
t.Errorf("created_at %q connected_at %q", row.CreatedAt, row.ConnectedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRefusesBadState(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// edit changes the connect, the callback's session or its query.
|
||||
edit func(t *testing.T, h *harness, c *connect, s *auth.Session, q url.Values)
|
||||
}{
|
||||
{"missing state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Del("state") }},
|
||||
{"wrong state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Set("state", "forged") }},
|
||||
{"no connect cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) { c.cookie = nil }},
|
||||
{"tampered cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) {
|
||||
c.cookie = &http.Cookie{Name: c.cookie.Name, Value: "x" + c.cookie.Value}
|
||||
}},
|
||||
{"another session", func(_ *testing.T, _ *harness, _ *connect, s *auth.Session, _ url.Values) { *s = other }},
|
||||
{"expired", func(_ *testing.T, h *harness, _ *connect, _ *auth.Session, _ url.Values) {
|
||||
h.clock = h.clock.Add(10 * time.Minute)
|
||||
}},
|
||||
{"state of another connect", func(t *testing.T, h *harness, _ *connect, _ *auth.Session, q url.Values) {
|
||||
q.Set("state", h.connect(t, session).consent.Query().Get("state"))
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
s := session
|
||||
q := h.consented(c)
|
||||
tc.edit(t, h, &c, &s, q)
|
||||
|
||||
rec, err := h.callback(c, s, q)
|
||||
if !errors.Is(err, gcal.ErrBadState) {
|
||||
t.Fatalf("Complete error = %v, want ErrBadState", err)
|
||||
}
|
||||
if h.google.TokenCalls() != 0 {
|
||||
t.Error("the code was exchanged despite the bad state")
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored")
|
||||
}
|
||||
assertCookieCleared(t, rec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompleteRefusesReplayedState presents a connect's cookie and state a
|
||||
// second time, with a second valid code from the same consent URL, as an
|
||||
// attacker holding a copy of the cookie could. The server refuses it,
|
||||
// whatever the browser did with the cleared cookie.
|
||||
func TestCompleteRefusesReplayedState(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
stored, _ := h.row(t)
|
||||
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrBadState) {
|
||||
t.Fatalf("replayed Complete error = %v, want ErrBadState", err)
|
||||
}
|
||||
if got := h.google.TokenCalls(); got != 1 {
|
||||
t.Errorf("token endpoint called %d times, want once: the replayed code was exchanged", got)
|
||||
}
|
||||
if row, _ := h.row(t); !bytes.Equal(row.RefreshTokenCt, stored.RefreshTokenCt) {
|
||||
t.Error("the replay replaced the stored token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRefusesMissingRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.OmitRefreshToken()
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrNoRefreshToken) {
|
||||
t.Fatalf("Complete error = %v, want ErrNoRefreshToken", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored without a refresh token")
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
}
|
||||
|
||||
func TestCompleteRefusesMissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.GrantScope("https://www.googleapis.com/auth/calendar.calendarlist.readonly")
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrScopes) {
|
||||
t.Fatalf("Complete error = %v, want ErrScopes", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored without calendar.events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
q := url.Values{"state": {c.consent.Query().Get("state")}, "error": {"access_denied"}}
|
||||
if _, err := h.callback(c, session, q); !errors.Is(err, gcal.ErrDenied) {
|
||||
t.Fatalf("Complete error = %v, want ErrDenied", err)
|
||||
}
|
||||
if h.google.TokenCalls() != 0 {
|
||||
t.Error("token endpoint called after a denial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteExchangeFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.FailToken(http.StatusBadRequest)
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if err == nil {
|
||||
t.Fatal("Complete succeeded despite the token endpoint failing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("error = %v, want the status", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRevokesAndClears(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if got := h.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
|
||||
t.Errorf("revoked %q, want the refresh token", got)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("integration still stored after disconnect")
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Errorf("second Disconnect = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectRacingReconnect drives a disconnect and a reconnect into
|
||||
// each other at Google, with distinct tokens. However they interleave, the
|
||||
// end state is consistent: disconnected, or connected with a token Google
|
||||
// has not revoked.
|
||||
func TestDisconnectRacingReconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// hold stops the request the other operation must not slip past.
|
||||
hold func(*gcaltest.Google) (<-chan struct{}, func())
|
||||
// first is held at Google while second runs.
|
||||
first, second func(h *harness, c connect, q url.Values) error
|
||||
}{
|
||||
{
|
||||
// The disconnect has read the stored token and is revoking it
|
||||
// when the reconnect would store a new one.
|
||||
name: "reconnect during revoke",
|
||||
hold: (*gcaltest.Google).HoldRevoke,
|
||||
first: disconnect,
|
||||
second: reconnect,
|
||||
},
|
||||
{
|
||||
// The reconnect's new token is issued but not yet stored when
|
||||
// the disconnect would revoke the old one.
|
||||
name: "disconnect during exchange",
|
||||
hold: (*gcaltest.Google).HoldToken,
|
||||
first: reconnect,
|
||||
second: disconnect,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
first := h.connect(t, session)
|
||||
if _, err := h.callback(first, session, h.consented(first)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := h.connect(t, session)
|
||||
q := h.consented(c)
|
||||
|
||||
arrived, release := tc.hold(h.google)
|
||||
defer release()
|
||||
firstDone := make(chan error, 1)
|
||||
go func() { firstDone <- tc.first(h, c, q) }()
|
||||
<-arrived
|
||||
secondDone := make(chan error, 1)
|
||||
go func() { secondDone <- tc.second(h, c, q) }()
|
||||
// Let the second operation run as far as it can while the first
|
||||
// is held at Google: to the end when nothing serializes them.
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
secondDone <- err
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
for _, done := range []chan error{firstDone, secondDone} {
|
||||
if err := <-done; err != nil {
|
||||
t.Logf("operation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
row, ok := h.row(t)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !h.google.Live(string(plain)) {
|
||||
t.Errorf("stored as connected with a revoked token (Google revoked %d tokens)", len(h.google.Revoked()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(h *harness, _ connect, _ url.Values) error {
|
||||
return h.svc.Disconnect(context.Background())
|
||||
}
|
||||
|
||||
func reconnect(h *harness, c connect, q url.Values) error {
|
||||
_, err := h.callback(c, session, q)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestDisconnectTokenAlreadyRevokedAtGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.AnswerRevoke(http.StatusBadRequest, `{"error":"invalid_token","error_description":"Token expired or revoked"}`)
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("integration still stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRevokeFailureKeepsConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} {
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.AnswerRevoke(status, "")
|
||||
err := h.svc.Disconnect(context.Background())
|
||||
var revoke *gcal.RevokeError
|
||||
if !errors.As(err, &revoke) {
|
||||
t.Fatalf("HTTP %d: Disconnect error = %v, want *RevokeError", status, err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); !ok {
|
||||
t.Errorf("HTTP %d: connection cleared although Google did not revoke", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectNotConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Fatalf("Disconnect = %v, want ErrNotConnected", err)
|
||||
}
|
||||
if len(h.google.Revoked()) != 0 {
|
||||
t.Error("revoke called with nothing connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
pub, _ := url.Parse(publicURL)
|
||||
valid := gcal.Config{
|
||||
ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"),
|
||||
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)
|
||||
}
|
||||
for name, edit := range map[string]func(*gcal.Config){
|
||||
"client ID": func(c *gcal.Config) { c.ClientID = "" },
|
||||
"client secret": func(c *gcal.Config) { c.ClientSecret = "" },
|
||||
"public URL": func(c *gcal.Config) { c.PublicURL = nil },
|
||||
"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)
|
||||
if _, err := gcal.NewService(h.st, h.box, cfg); err == nil {
|
||||
t.Errorf("NewService accepted a config without the %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "__Host-mealquest_google" && c.MaxAge < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("connect cookie not cleared")
|
||||
}
|
||||
|
||||
// assertNoSecret fails when text holds the refresh token, the access token
|
||||
// or the client secret.
|
||||
func assertNoSecret(t *testing.T, what, text string) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Errorf("%s contains a secret: %s", what, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headerText(h http.Header) string {
|
||||
var b strings.Builder
|
||||
for k, vs := range h {
|
||||
b.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxEventBody caps how much of an events response is read.
|
||||
const maxEventBody = 64 << 10
|
||||
|
||||
var (
|
||||
// ErrNoCalendar means Google Calendar is connected but no calendar is
|
||||
// chosen to push to.
|
||||
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
|
||||
// ErrCalendarUnavailable means the chosen calendar was deleted or the
|
||||
// connected account can no longer add events to it. Such a calendar
|
||||
// cannot be chosen again; the household must choose another.
|
||||
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
|
||||
)
|
||||
|
||||
// Google's rule for a client-chosen event id: base32hex characters
|
||||
// (lowercase a-v and 0-9), 5 to 1024 of them.
|
||||
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
|
||||
|
||||
const (
|
||||
minEventID = 5
|
||||
maxEventID = 1024
|
||||
)
|
||||
|
||||
// Event is one event to write to the chosen calendar.
|
||||
type Event struct {
|
||||
// ID is the event's id, chosen by the caller so that writing the same
|
||||
// event again updates it; it must satisfy Google's id rule.
|
||||
ID string
|
||||
Summary string
|
||||
Description string
|
||||
Start, End time.Time
|
||||
// TimeZone is the IANA zone the event is in, such as America/New_York.
|
||||
TimeZone string
|
||||
}
|
||||
|
||||
func (e Event) validate() error {
|
||||
switch {
|
||||
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
|
||||
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
|
||||
case e.Summary == "":
|
||||
return fmt.Errorf("gcal: event %s has no summary", e.ID)
|
||||
case !e.End.After(e.Start):
|
||||
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
|
||||
case e.TimeZone == "":
|
||||
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventBody is an event as the Calendar API takes it.
|
||||
type eventBody struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Start eventTime `json:"start"`
|
||||
End eventTime `json:"end"`
|
||||
}
|
||||
|
||||
type eventTime struct {
|
||||
DateTime string `json:"dateTime"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
}
|
||||
|
||||
// PushEvents writes events, in order, to the chosen calendar: each is
|
||||
// inserted with its id, and an id Google already has (HTTP 409, which
|
||||
// includes an event deleted in Calendar) is updated in place instead, so
|
||||
// writing an event again never adds a second one. It stops at the first
|
||||
// failure; the events before it stay written, and writing them all again
|
||||
// is safe. Nothing connected is ErrNotConnected, a grant already recorded
|
||||
// as refused is ErrReconnect whatever calendar is chosen, no calendar
|
||||
// chosen is ErrNoCalendar, a chosen calendar that was deleted or is no longer
|
||||
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
|
||||
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
|
||||
// other Google failure is a *CalendarError, retryable when Google answered
|
||||
// every try with HTTP 429 or 5xx.
|
||||
//
|
||||
// Once every event is written, PushEvents returns record(), which the
|
||||
// caller uses to store what it pushed. record runs under the same hold of
|
||||
// the account as the writes, so the chosen calendar cannot change between
|
||||
// them; it is not run when a write fails.
|
||||
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
|
||||
if len(events) == 0 {
|
||||
return errors.New("gcal: no events to push")
|
||||
}
|
||||
for _, e := range events {
|
||||
if err := e.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
|
||||
}
|
||||
|
||||
// pushEvents is PushEvents once the events are valid. The caller holds
|
||||
// s.account. A refused grant is reported before no calendar chosen
|
||||
// (grantedConnection): choosing a calendar cannot fix it, connecting again
|
||||
// can.
|
||||
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
|
||||
row, err := s.grantedConnection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !row.CalendarID.Valid || row.CalendarID.String == "" {
|
||||
return ErrNoCalendar
|
||||
}
|
||||
access, err := s.accessToken(ctx, row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
|
||||
for _, e := range events {
|
||||
if err := s.writeEvent(ctx, access, base, e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return record()
|
||||
}
|
||||
|
||||
// writeEvent inserts e at base, the chosen calendar's events collection,
|
||||
// and updates it when Google already has its id.
|
||||
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
|
||||
body, err := json.Marshal(eventBody{
|
||||
ID: e.ID,
|
||||
Status: "confirmed",
|
||||
Summary: e.Summary,
|
||||
Description: e.Description,
|
||||
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
|
||||
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
|
||||
}
|
||||
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusConflict {
|
||||
return nil
|
||||
}
|
||||
// The status "confirmed" in the body restores an event deleted in
|
||||
// Calendar, which Google keeps as cancelled under the same id.
|
||||
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
|
||||
return err
|
||||
}
|
||||
|
||||
// eventRequest sends an event insert (POST) or update (PUT), retrying a
|
||||
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
|
||||
// every other answer is an error. An insert Google took but answered with
|
||||
// a transient failure is answered 409 when retried, and updated.
|
||||
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
|
||||
what := "event insert"
|
||||
if method == http.MethodPut {
|
||||
what = "event update"
|
||||
}
|
||||
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
return http.StatusOK, nil
|
||||
case status == http.StatusConflict && method == http.MethodPost:
|
||||
return http.StatusConflict, nil
|
||||
case status == http.StatusUnauthorized:
|
||||
// The access token was just refreshed from the grant.
|
||||
return 0, ErrReconnect
|
||||
case status == http.StatusNotFound && method == http.MethodPost,
|
||||
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
|
||||
// An insert into a deleted calendar, or one the account no longer
|
||||
// sees, is not found; a write to one it can only read is
|
||||
// requiredAccessLevel. An update not found is the event missing
|
||||
// from the calendar after Google reported its id taken, which
|
||||
// says nothing about the calendar.
|
||||
return 0, ErrCalendarUnavailable
|
||||
}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
|
||||
}
|
||||
|
||||
// errorReason is the reason of the first error in a Calendar API error
|
||||
// body, or "" when there is none. Only the reason is read; the rest of
|
||||
// Google's text is never kept.
|
||||
func errorReason(body []byte) string {
|
||||
var reply struct {
|
||||
Error struct {
|
||||
Errors []struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"errors"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return reply.Error.Errors[0].Reason
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package gcal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
)
|
||||
|
||||
// chosen is a harness connected with dinners chosen as the calendar to
|
||||
// push to.
|
||||
func chosen(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
h := connected(t)
|
||||
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// noRecord is a PushEvents record that stores nothing.
|
||||
func noRecord() error { return nil }
|
||||
|
||||
// event is a valid event with id.
|
||||
func event(id string) gcal.Event {
|
||||
start := time.Date(2026, 10, 12, 21, 30, 0, 0, time.UTC)
|
||||
return gcal.Event{ID: id, Summary: "Dinner: Beef Chili", Description: "Beef Chili",
|
||||
Start: start, End: start.Add(time.Hour), TimeZone: "America/New_York"}
|
||||
}
|
||||
|
||||
func TestPushEventsInsertsThenUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}, noRecord); err != nil {
|
||||
t.Fatalf("PushEvents: %v", err)
|
||||
}
|
||||
renamed := event("abcde0")
|
||||
renamed.Summary = "Dinner: Pot Roast"
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{renamed}, noRecord); err != nil {
|
||||
t.Fatalf("PushEvents again: %v", err)
|
||||
}
|
||||
if inserts, updates := h.google.EventWrites(); inserts != 2 || updates != 1 {
|
||||
t.Errorf("%d inserts and %d updates, want 2 and 1", inserts, updates)
|
||||
}
|
||||
events := h.google.Events(dinners.ID)
|
||||
if len(events) != 2 || events["abcde0"].Summary != "Dinner: Pot Roast" {
|
||||
t.Errorf("events = %+v", events)
|
||||
}
|
||||
if got := events["abcde1"].Start.DateTime; got != "2026-10-12T21:30:00Z" {
|
||||
t.Errorf("start = %s, want the instant as given", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushEventsRefusesInvalidEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
before := h.google.TokenCalls()
|
||||
for name, edit := range map[string]func(*gcal.Event){
|
||||
"uppercase id": func(e *gcal.Event) { e.ID = "ABCDE" },
|
||||
"id outside a-v": func(e *gcal.Event) { e.ID = "abcdw" },
|
||||
"id too short": func(e *gcal.Event) { e.ID = "abcd" },
|
||||
"id too long": func(e *gcal.Event) { e.ID = strings.Repeat("a", 1025) },
|
||||
"no summary": func(e *gcal.Event) { e.Summary = "" },
|
||||
"ends as it starts": func(e *gcal.Event) { e.End = e.Start },
|
||||
"no time zone": func(e *gcal.Event) { e.TimeZone = "" },
|
||||
} {
|
||||
ev := event("abcde")
|
||||
edit(&ev)
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}, noRecord); err == nil {
|
||||
t.Errorf("%s: PushEvents succeeded", name)
|
||||
}
|
||||
}
|
||||
if err := h.svc.PushEvents(context.Background(), nil, noRecord); err == nil {
|
||||
t.Error("PushEvents of no events succeeded")
|
||||
}
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}, noRecord); err != nil {
|
||||
t.Errorf("PushEvents of a 1024-character id: %v", err)
|
||||
}
|
||||
if inserts, _ := h.google.EventWrites(); inserts != 1 {
|
||||
t.Errorf("%d events inserted, want only the valid one", inserts)
|
||||
}
|
||||
if n := h.google.TokenCalls() - before; n != 1 {
|
||||
t.Errorf("token endpoint called %d times, want once, for the valid push", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushEventsGrantNoLongerAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
h.google.FailEventWrite(1, http.StatusUnauthorized)
|
||||
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
|
||||
t.Error("record ran after a failed write")
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("PushEvents = %v, want ErrReconnect", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); !ok {
|
||||
t.Error("a refused grant forgot the connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, n := range map[string]int{"insert": 1, "update": 2} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
if name == "update" {
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The fake's failure body quotes the access token.
|
||||
h.google.FailEventWrite(n, http.StatusBadRequest)
|
||||
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
|
||||
var calendar *gcal.CalendarError
|
||||
if !errors.As(err, &calendar) {
|
||||
t.Fatalf("PushEvents = %v, want *CalendarError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("error = %q, want the failed %s and its status", err, name)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushEventsHoldsTheAccount holds an event write at Google while a
|
||||
// disconnect starts: the disconnect waits for the push, so it never
|
||||
// revokes the token the push is using.
|
||||
func TestPushEventsHoldsTheAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
arrived, release := h.google.HoldEvents()
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() { pushed <- h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord) }()
|
||||
<-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 push 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 push was at Google", n)
|
||||
}
|
||||
release()
|
||||
if err := <-pushed; err != nil {
|
||||
t.Errorf("PushEvents: %v", err)
|
||||
}
|
||||
if err := <-disconnected; err != nil {
|
||||
t.Errorf("Disconnect: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushEventsRecordsWhileTheCalendarIsChosen chooses another calendar
|
||||
// while a push records: the choice waits for the record, so a push never
|
||||
// records events on a calendar no longer chosen.
|
||||
func TestPushEventsRecordsWhileTheCalendarIsChosen(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
selected := make(chan error, 1)
|
||||
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
|
||||
go func() {
|
||||
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
|
||||
selected <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-selected:
|
||||
t.Errorf("SelectCalendar finished (%v) while a push was recording", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PushEvents: %v", err)
|
||||
}
|
||||
if err := <-selected; err != nil {
|
||||
t.Errorf("SelectCalendar: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package gcal
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock replaces s's clock.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
|
||||
// SetRetryDelay replaces s's wait before the first retry of a transient
|
||||
// Google failure.
|
||||
func SetRetryDelay(s *Service, d time.Duration) { s.retryDelay = d }
|
||||
@@ -0,0 +1,749 @@
|
||||
// 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; a calendarList endpoint that requires a live grant's access
|
||||
// token; and events insert and update endpoints that, like Google's, keep
|
||||
// each calendar's events by id, answer an insert of an id the calendar
|
||||
// already has (even a cancelled event's) with HTTP 409, and refuse a
|
||||
// calendar not listed (404) or not writable (403 requiredAccessLevel). 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"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
)
|
||||
|
||||
// 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. Every
|
||||
// exchange and refresh issues AccessToken.
|
||||
const (
|
||||
ClientID = "mealquest-test.apps.googleusercontent.com"
|
||||
ClientSecret = "google-client-s3cret-4f1c"
|
||||
RefreshToken = "1//google-refresh-t0ken-9a8b7c"
|
||||
AccessToken = "ya29.google-access-t0ken-5d6e"
|
||||
)
|
||||
|
||||
// Google is the fake. Its exported fields script the next responses; set
|
||||
// them before the request they affect.
|
||||
type Google struct {
|
||||
Server *httptest.Server
|
||||
// RedirectURL is the redirect URI the token endpoint requires.
|
||||
RedirectURL string
|
||||
|
||||
mu sync.Mutex
|
||||
// omitRefresh leaves refresh_token out of the token response.
|
||||
omitRefresh bool
|
||||
// scope is the token response's granted scopes.
|
||||
scope string
|
||||
// tokenStatus, when not 200, fails the token endpoint with a body that
|
||||
// quotes the refresh token and client secret.
|
||||
tokenStatus int
|
||||
// revokeStatus and revokeBody answer the revoke endpoint; revokeBody
|
||||
// quotes the token when revokeStatus is not 200 or 400.
|
||||
revokeStatus int
|
||||
revokeBody string
|
||||
codes map[string]string // code -> PKCE challenge
|
||||
tokenCalls int
|
||||
revoked []string
|
||||
// issued is every refresh token issued, in order; dead is those a
|
||||
// revoke has invalidated.
|
||||
issued []string
|
||||
dead map[string]bool
|
||||
// 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: every request when calendarFails
|
||||
// is 0, else the next calendarFails requests.
|
||||
calendarStatus int
|
||||
calendarFails int
|
||||
calendarCalls int
|
||||
// rejectAccess makes the Calendar API answer HTTP 401 to every access
|
||||
// token, while the grant stays live at the token endpoint.
|
||||
rejectAccess bool
|
||||
// events holds each calendar's events by id.
|
||||
events map[string]map[string]Event
|
||||
// inserts and updates count the events created and updated.
|
||||
inserts, updates int
|
||||
// eventWrites counts the requests that reached the events endpoints;
|
||||
// failWrite, when not 0, fails the requests that make it that count up
|
||||
// to failLast with failStatus and a body that quotes the access token.
|
||||
eventWrites, failWrite, failLast, failStatus int
|
||||
// eventHold, when set, stops the next events request.
|
||||
eventHold *hold
|
||||
}
|
||||
|
||||
// Event is an event as the fake keeps it: what MealQuest sent.
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Start EventTime `json:"start"`
|
||||
End EventTime `json:"end"`
|
||||
}
|
||||
|
||||
// EventTime is an event's start or end.
|
||||
type EventTime struct {
|
||||
DateTime string `json:"dateTime"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// once release is closed.
|
||||
type hold struct {
|
||||
arrived, release chan struct{}
|
||||
}
|
||||
|
||||
// New starts a fake Google that requires redirectURL at its token endpoint.
|
||||
// It grants every scope in gcal.Scopes with a refresh token by default.
|
||||
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,
|
||||
calendarStatus: http.StatusOK,
|
||||
codes: map[string]string{},
|
||||
dead: map[string]bool{},
|
||||
events: map[string]map[string]Event{},
|
||||
}
|
||||
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)
|
||||
mux.HandleFunc("POST /calendar/v3/calendars/{calendar}/events", g.insertEvent)
|
||||
mux.HandleFunc("PUT /calendar/v3/calendars/{calendar}/events/{event}", g.updateEvent)
|
||||
g.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(g.Server.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
// Endpoint is the fake's authorization and token endpoint, with Google's
|
||||
// client authentication style.
|
||||
func (g *Google) Endpoint() oauth2.Endpoint {
|
||||
return oauth2.Endpoint{
|
||||
AuthURL: g.Server.URL + "/auth",
|
||||
TokenURL: g.Server.URL + "/token",
|
||||
AuthStyle: gcal.GoogleEndpoint.AuthStyle,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
code := fmt.Sprintf("code-%d", len(g.codes)+1)
|
||||
g.codes[code] = challenge
|
||||
return code
|
||||
}
|
||||
|
||||
// Connect connects svc to the fake as a household completing the consent
|
||||
// screen does. svc must use the fake's endpoints and redirect URL.
|
||||
func (g *Google) Connect(t *testing.T, svc *gcal.Service) {
|
||||
t.Helper()
|
||||
session := auth.Session{Subject: "gcaltest", CSRFToken: "gcaltest-session"}
|
||||
started := httptest.NewRecorder()
|
||||
raw, err := svc.Connect(started, session)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consent, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := url.Values{
|
||||
"state": {consent.Query().Get("state")},
|
||||
"code": {g.Authorize(consent.Query().Get("code_challenge"))},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+q.Encode(), nil)
|
||||
for _, c := range started.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
if err := svc.Complete(httptest.NewRecorder(), req, session); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OmitRefreshToken makes the token endpoint answer without a refresh token,
|
||||
// as Google does when it reuses an earlier grant.
|
||||
func (g *Google) OmitRefreshToken() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.omitRefresh = true
|
||||
}
|
||||
|
||||
// GrantScope sets the scopes the token endpoint reports as granted.
|
||||
func (g *Google) GrantScope(scope string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.scope = scope
|
||||
}
|
||||
|
||||
// FailToken makes the token endpoint answer status with a body that quotes
|
||||
// the refresh token and client secret.
|
||||
func (g *Google) FailToken(status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.tokenStatus = status
|
||||
}
|
||||
|
||||
// AnswerRevoke makes the revoke endpoint answer status with body. A body of
|
||||
// "" with a status other than 200 quotes the token it was sent.
|
||||
func (g *Google) AnswerRevoke(status int, body string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.revokeStatus, g.revokeBody = status, body
|
||||
}
|
||||
|
||||
// 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, g.calendarFails = status, 0
|
||||
}
|
||||
|
||||
// FailCalendarListNext makes the next n calendarList requests answer status
|
||||
// as FailCalendarList does, and later ones answer normally.
|
||||
func (g *Google) FailCalendarListNext(n, status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.calendarStatus, g.calendarFails = status, n
|
||||
}
|
||||
|
||||
// RejectAccessToken makes the Calendar API answer HTTP 401 to every access
|
||||
// token while refreshing the grant still succeeds, as Google does when it
|
||||
// refuses the access token it just issued.
|
||||
func (g *Google) RejectAccessToken() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.rejectAccess = true
|
||||
}
|
||||
|
||||
// AcceptAccessToken undoes RejectAccessToken, as Google does for the grant
|
||||
// of a later connection.
|
||||
func (g *Google) AcceptAccessToken() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.rejectAccess = false
|
||||
}
|
||||
|
||||
// CalendarCalls is how many requests reached the calendarList endpoint.
|
||||
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()
|
||||
}
|
||||
|
||||
// Events returns the events of calendar, by id.
|
||||
func (g *Google) Events(calendar string) map[string]Event {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := map[string]Event{}
|
||||
for id, e := range g.events[calendar] {
|
||||
out[id] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EventWrites returns how many events were inserted and how many updated.
|
||||
func (g *Google) EventWrites() (inserts, updates int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.inserts, g.updates
|
||||
}
|
||||
|
||||
// ResetEventWrites zeroes the insert and update counts.
|
||||
func (g *Google) ResetEventWrites() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.inserts, g.updates = 0, 0
|
||||
}
|
||||
|
||||
// CancelEvent marks event id of calendar cancelled, as deleting it in
|
||||
// Google Calendar does: Google keeps the id.
|
||||
func (g *Google) CancelEvent(calendar, id string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
e := g.events[calendar][id]
|
||||
e.Status = "cancelled"
|
||||
g.events[calendar][id] = e
|
||||
}
|
||||
|
||||
// FailEventWrite makes the nth request to the events endpoints from now
|
||||
// (1 for the next) answer status with a body that quotes the access token.
|
||||
func (g *Google) FailEventWrite(n, status int) {
|
||||
g.FailEventWrites(n, 1, status)
|
||||
}
|
||||
|
||||
// FailEventWrites makes count requests to the events endpoints in a row,
|
||||
// from the nth from now (1 for the next), answer status with a body that
|
||||
// quotes the access token.
|
||||
func (g *Google) FailEventWrites(n, count, status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.failWrite, g.failLast, g.failStatus = g.eventWrites+n, g.eventWrites+n+count-1, status
|
||||
}
|
||||
|
||||
// EventRequests is how many requests reached the events endpoints.
|
||||
func (g *Google) EventRequests() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.eventWrites
|
||||
}
|
||||
|
||||
// HoldEvents stops the next request to the events endpoints before it is
|
||||
// answered. It returns a channel closed when the request arrives and the
|
||||
// function that lets it go on.
|
||||
func (g *Google) HoldEvents() (arrived <-chan struct{}, release func()) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.eventHold = newHold()
|
||||
return g.eventHold.arrived, g.eventHold.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()
|
||||
return g.tokenCalls
|
||||
}
|
||||
|
||||
// Revoked lists the tokens the revoke endpoint received, in order.
|
||||
func (g *Google) Revoked() []string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return append([]string(nil), g.revoked...)
|
||||
}
|
||||
|
||||
// Live reports whether token was issued by the fake and no revoke has
|
||||
// invalidated it since.
|
||||
func (g *Google) Live(token string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return slices.Contains(g.issued, token) && !g.dead[token]
|
||||
}
|
||||
|
||||
// HoldToken stops the next token request after its refresh token is issued
|
||||
// and before it is answered, so a revoke can land in between. It returns a
|
||||
// channel closed when the request arrives and the function that lets it go
|
||||
// on.
|
||||
func (g *Google) HoldToken() (arrived <-chan struct{}, release func()) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.tokenHold = newHold()
|
||||
return g.tokenHold.arrived, g.tokenHold.releaseFunc()
|
||||
}
|
||||
|
||||
// HoldRevoke stops the next revoke request before Google acts on it. It
|
||||
// returns a channel closed when the request arrives and the function that
|
||||
// lets it go on.
|
||||
func (g *Google) HoldRevoke() (arrived <-chan struct{}, release func()) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.revokeHold = newHold()
|
||||
return g.revokeHold.arrived, g.revokeHold.releaseFunc()
|
||||
}
|
||||
|
||||
func newHold() *hold {
|
||||
return &hold{arrived: make(chan struct{}), release: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (h *hold) releaseFunc() func() {
|
||||
var once sync.Once
|
||||
return func() { once.Do(func() { close(h.release) }) }
|
||||
}
|
||||
|
||||
// wait stops the request at h, if any, without holding g.mu.
|
||||
func (g *Google) wait(h *hold) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Unlock()
|
||||
close(h.arrived)
|
||||
<-h.release
|
||||
g.mu.Lock()
|
||||
}
|
||||
|
||||
func (g *Google) token(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.tokenCalls++
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
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 {
|
||||
case f.Get("grant_type") != "authorization_code":
|
||||
oauthError(w, http.StatusBadRequest, "unsupported_grant_type")
|
||||
return
|
||||
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
|
||||
oauthError(w, http.StatusUnauthorized, "invalid_client")
|
||||
return
|
||||
case !known, f.Get("redirect_uri") != g.RedirectURL, s256(f.Get("code_verifier")) != challenge:
|
||||
oauthError(w, http.StatusBadRequest, "invalid_grant")
|
||||
return
|
||||
}
|
||||
if g.tokenStatus != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(g.tokenStatus)
|
||||
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"echo %s %s"}`, RefreshToken, ClientSecret)
|
||||
return
|
||||
}
|
||||
refresh := ""
|
||||
if !g.omitRefresh {
|
||||
issue := RefreshToken
|
||||
if n := len(g.issued); n > 0 {
|
||||
issue = fmt.Sprintf("%s-%d", RefreshToken, n+1)
|
||||
}
|
||||
g.issued = append(g.issued, issue)
|
||||
refresh = fmt.Sprintf(`"refresh_token":%q,`, issue)
|
||||
}
|
||||
h := g.tokenHold
|
||||
g.tokenHold = nil
|
||||
g.wait(h)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"access_token":%q,%s"expires_in":3599,"scope":%q,"token_type":"Bearer"}`,
|
||||
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() || g.rejectAccess {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":{"code":401,"message":"Invalid Credentials: %s"}}`, auth)
|
||||
return
|
||||
}
|
||||
if status := g.calendarStatus; status != http.StatusOK {
|
||||
if g.calendarFails > 0 {
|
||||
g.calendarFails--
|
||||
if g.calendarFails == 0 {
|
||||
g.calendarStatus = http.StatusOK
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, status, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// insertEvent answers like Google's events.insert with a client-chosen id.
|
||||
func (g *Google) insertEvent(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
cal, ok := g.eventCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var e Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" {
|
||||
http.Error(w, "bad event", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, exists := g.events[cal][e.ID]; exists {
|
||||
apiError(w, http.StatusConflict, "duplicate", "The requested identifier already exists.")
|
||||
return
|
||||
}
|
||||
if g.events[cal] == nil {
|
||||
g.events[cal] = map[string]Event{}
|
||||
}
|
||||
g.events[cal][e.ID] = e
|
||||
g.inserts++
|
||||
writeEvent(w, e)
|
||||
}
|
||||
|
||||
// updateEvent answers like Google's events.update.
|
||||
func (g *Google) updateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
cal, ok := g.eventCalendar(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("event")
|
||||
if _, exists := g.events[cal][id]; !exists {
|
||||
apiError(w, http.StatusNotFound, "notFound", "Not Found")
|
||||
return
|
||||
}
|
||||
var e Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID != id {
|
||||
http.Error(w, "bad event", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
g.events[cal][id] = e
|
||||
g.updates++
|
||||
writeEvent(w, e)
|
||||
}
|
||||
|
||||
// eventCalendar runs the checks every events request shares: the hold, a
|
||||
// live grant's access token, a scripted failure, and a listed, writable
|
||||
// calendar, which it returns. When a check fails it answers and returns
|
||||
// false. The caller holds g.mu.
|
||||
func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
g.eventWrites++
|
||||
h := g.eventHold
|
||||
g.eventHold = nil
|
||||
g.wait(h)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
|
||||
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
|
||||
return "", false
|
||||
}
|
||||
if g.failWrite != 0 && g.eventWrites >= g.failWrite && g.eventWrites <= g.failLast {
|
||||
apiError(w, g.failStatus, "backendError", "echo "+auth)
|
||||
return "", false
|
||||
}
|
||||
cal := r.PathValue("calendar")
|
||||
i := slices.IndexFunc(g.calendars, func(c Calendar) bool { return c.ID == cal })
|
||||
switch {
|
||||
case i < 0:
|
||||
apiError(w, http.StatusNotFound, "notFound", "Not Found")
|
||||
return "", false
|
||||
case g.calendars[i].AccessRole != "writer" && g.calendars[i].AccessRole != "owner":
|
||||
apiError(w, http.StatusForbidden, "requiredAccessLevel", "You need to have writer access to this calendar.")
|
||||
return "", false
|
||||
}
|
||||
return cal, true
|
||||
}
|
||||
|
||||
// writeEvent answers with e as Google does, with fields of its own.
|
||||
func writeEvent(w http.ResponseWriter, e Event) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"kind": "calendar#event", "etag": `"3300000000000000"`, "id": e.ID, "status": e.Status,
|
||||
"htmlLink": "https://www.google.com/calendar/event?eid=" + e.ID,
|
||||
"summary": e.Summary, "description": e.Description, "start": e.Start, "end": e.End,
|
||||
"iCalUID": e.ID + "@google.com", "sequence": 0,
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// apiError answers a Calendar API error with reason and message.
|
||||
func apiError(w http.ResponseWriter, status int, reason, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
||||
"code": status, "message": message,
|
||||
"errors": []map[string]any{{"domain": "global", "reason": reason, "message": message}},
|
||||
}}); 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()
|
||||
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||
http.Error(w, "want a form", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h := g.revokeHold
|
||||
g.revokeHold = nil
|
||||
g.wait(h)
|
||||
token := r.PostForm.Get("token")
|
||||
g.revoked = append(g.revoked, token)
|
||||
if g.revokeStatus == http.StatusOK && slices.Contains(g.issued, token) {
|
||||
// Google revokes the grant, not just the token: every token issued
|
||||
// to it so far stops working.
|
||||
for _, t := range g.issued {
|
||||
g.dead[t] = true
|
||||
}
|
||||
}
|
||||
body := g.revokeBody
|
||||
if body == "" && g.revokeStatus != http.StatusOK {
|
||||
body = "cannot revoke " + token
|
||||
}
|
||||
w.WriteHeader(g.revokeStatus)
|
||||
fmt.Fprint(w, body)
|
||||
}
|
||||
|
||||
func oauthError(w http.ResponseWriter, status int, code string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":%q}`, code)
|
||||
}
|
||||
|
||||
// s256 is the PKCE S256 challenge for verifier.
|
||||
func s256(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// ReconnectReason is the last_sync_error stored when Google refuses the
|
||||
// stored grant.
|
||||
const ReconnectReason = "Google access was revoked or expired — reconnect"
|
||||
|
||||
const (
|
||||
// maxRetries is how many times a request Google answers with a
|
||||
// transient failure (HTTP 429 or 5xx) is sent again.
|
||||
maxRetries = 2
|
||||
// firstRetryDelay is the wait before the first retry; each later retry
|
||||
// waits twice as long as the one before.
|
||||
firstRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// The states Status reports.
|
||||
const (
|
||||
// StatusNotConnected: no Google account is connected.
|
||||
StatusNotConnected = "not_connected"
|
||||
// StatusConnected: a Google account is connected and its grant was last
|
||||
// accepted.
|
||||
StatusConnected = "connected"
|
||||
// StatusReconnectRequired: Google refused the stored grant, so it was
|
||||
// cleared; only connecting again restores Push to Calendar.
|
||||
StatusReconnectRequired = "reconnect_required"
|
||||
)
|
||||
|
||||
// Status is the Google Calendar connection as the household sees it. It
|
||||
// never holds a token.
|
||||
type Status struct {
|
||||
State string `json:"status"`
|
||||
CalendarID *string `json:"calendar_id"`
|
||||
CalendarName *string `json:"calendar_name"`
|
||||
ConnectedAt *string `json:"connected_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
}
|
||||
|
||||
// Status reports the connection: not connected, connected, or reconnect
|
||||
// required, with the chosen calendar, when it was connected and the last
|
||||
// sync error. It is one read of one row, so it takes no lock.
|
||||
func (s *Service) Status(ctx context.Context) (Status, error) {
|
||||
row, err := s.connection(ctx)
|
||||
if errors.Is(err, ErrNotConnected) {
|
||||
return Status{State: StatusNotConnected}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
st := Status{
|
||||
State: StatusConnected,
|
||||
CalendarID: nullable(row.CalendarID),
|
||||
CalendarName: nullable(row.CalendarName),
|
||||
ConnectedAt: &row.ConnectedAt,
|
||||
LastSyncError: nullable(row.LastSyncError),
|
||||
}
|
||||
if row.RefreshTokenCt == nil {
|
||||
st.State = StatusReconnectRequired
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func nullable(v sql.NullString) *string {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
return &v.String
|
||||
}
|
||||
|
||||
// recordRefusal returns err, first recording, when it is ErrReconnect, that
|
||||
// Google refuses the stored grant: the refresh token is cleared and
|
||||
// last_sync_error set to ReconnectReason, so Status reports reconnect
|
||||
// required and no request offers Google the refused token again.
|
||||
//
|
||||
// The chosen calendar and every stored calendar event id are kept, and
|
||||
// forgetEvents is deliberately not run: the events MealQuest pushed are
|
||||
// still on the chosen calendar, so a stored calendar_event_id still names
|
||||
// an event on the chosen calendar. Reconnecting keeps the choice (Complete),
|
||||
// and the next push updates those events in place. The caller holds
|
||||
// s.account, which every push and calendar choice also holds, so the
|
||||
// refusal is recorded between them, never during one.
|
||||
func (s *Service) recordRefusal(ctx context.Context, err error) error {
|
||||
if !errors.Is(err, ErrReconnect) {
|
||||
return err
|
||||
}
|
||||
cleared := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
n, err := db.New(tx).ClearIntegrationsGoogleToken(ctx, db.ClearIntegrationsGoogleTokenParams{
|
||||
LastSyncError: sql.NullString{String: ReconnectReason, Valid: true},
|
||||
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
// The lock is held since the connection was read, so only a
|
||||
// change outside this service gets here.
|
||||
return ErrNotConnected
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if cleared != nil {
|
||||
return fmt.Errorf("gcal: record that Google refused the grant: %w", cleared)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// transient reports whether Google's HTTP status is a failure worth
|
||||
// retrying: rate limited (429) or a server error (5xx).
|
||||
func transient(status int) bool {
|
||||
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// backoff waits before retry number attempt (1 for the first), or until ctx
|
||||
// ends.
|
||||
func (s *Service) backoff(ctx context.Context, attempt int) error {
|
||||
t := time.NewTimer(s.retryDelay << (attempt - 1))
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("gcal: wait to retry: %w", ctx.Err())
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// send sends the request newRequest builds, and sends it again, up to
|
||||
// maxRetries times, while Google answers it with a transient failure. It
|
||||
// returns the final answer's status and up to limit bytes of its body. A
|
||||
// transient failure on the last try is a retryable *CalendarError; what
|
||||
// names the request in errors, which never hold the URL, a token or
|
||||
// Google's response body.
|
||||
func (s *Service) send(ctx context.Context, what string, limit int64, newRequest func() (*http.Request, error)) (int, []byte, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := s.backoff(ctx, attempt); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
req, err := newRequest()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gcal: build %s request: %w", what, err)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
// The URL is dropped: after a refused redirect it is the
|
||||
// Google-supplied Location.
|
||||
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
|
||||
}
|
||||
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
|
||||
}
|
||||
if !transient(resp.StatusCode) {
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
if attempt == maxRetries {
|
||||
return 0, nil, &CalendarError{retryable: true,
|
||||
msg: fmt.Sprintf("gcal: Google could not take the %s (HTTP %d) in %d tries", what, resp.StatusCode, maxRetries+1)}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package gcal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
)
|
||||
|
||||
// refusals are the ways Google refuses the stored grant: invalid_grant on
|
||||
// the token refresh, and HTTP 401 from the Calendar API to the access token
|
||||
// just refreshed.
|
||||
var refusals = map[string]func(*gcaltest.Google){
|
||||
"invalid_grant": (*gcaltest.Google).RevokeGrant,
|
||||
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
|
||||
}
|
||||
|
||||
// calls are the Calendar calls that use the stored grant.
|
||||
var calls = map[string]func(*harness) error{
|
||||
"list calendars": func(h *harness) error {
|
||||
_, err := h.svc.Calendars(context.Background())
|
||||
return err
|
||||
},
|
||||
"choose a calendar": func(h *harness) error {
|
||||
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
|
||||
return err
|
||||
},
|
||||
"push events": func(h *harness) error {
|
||||
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
|
||||
return errors.New("record ran after a refused grant")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// TestRefusedGrantRequiresReconnect refuses the grant on every Calendar
|
||||
// call: the call fails with ErrReconnect, the refresh token is cleared,
|
||||
// last_sync_error says why, the chosen calendar is kept and Status reports
|
||||
// reconnect required.
|
||||
func TestRefusedGrantRequiresReconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
for refusal, refuse := range refusals {
|
||||
for call, do := range calls {
|
||||
t.Run(refusal+"/"+call, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
refuse(h.google)
|
||||
h.clock = h.clock.Add(time.Hour)
|
||||
err := do(h)
|
||||
if !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("%s = %v, want ErrReconnect", call, err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
row, ok := h.row(t)
|
||||
switch {
|
||||
case !ok:
|
||||
t.Fatal("a refused grant forgot the connection")
|
||||
case row.RefreshTokenCt != nil:
|
||||
t.Error("refresh token kept after Google refused it")
|
||||
case row.LastSyncError.String != "Google access was revoked or expired — reconnect":
|
||||
t.Errorf("last_sync_error = %v", row.LastSyncError)
|
||||
case row.CalendarID.String != dinners.ID || row.CalendarName.String != dinners.Summary:
|
||||
t.Errorf("calendar = %v %v, want %s kept", row.CalendarID, row.CalendarName, dinners.ID)
|
||||
case row.UpdatedAt != "2026-10-07T19:30:00.000Z":
|
||||
t.Errorf("updated_at = %s, want the refusal's time", row.UpdatedAt)
|
||||
}
|
||||
st, err := h.svc.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State != gcal.StatusReconnectRequired || st.LastSyncError == nil || *st.LastSyncError != gcal.ReconnectReason {
|
||||
t.Errorf("Status = %+v, want reconnect_required with the reason", st)
|
||||
}
|
||||
|
||||
// The cleared grant is never offered to Google again, by
|
||||
// any call.
|
||||
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
|
||||
for again, do := range calls {
|
||||
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
|
||||
}
|
||||
}
|
||||
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
|
||||
t.Error("a call reached Google after the grant was refused")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedGrantBeforeACalendarIsChosen refuses the grant while no
|
||||
// calendar is chosen, on each call that can record it then (listing and
|
||||
// choosing): every Calendar call, push included, is ErrReconnect rather than
|
||||
// ErrNoCalendar, since only connecting again restores the grant. After
|
||||
// connecting again and choosing a calendar, a push succeeds.
|
||||
func TestRefusedGrantBeforeACalendarIsChosen(t *testing.T) {
|
||||
t.Parallel()
|
||||
for refusal, refuse := range refusals {
|
||||
for _, call := range []string{"list calendars", "choose a calendar"} {
|
||||
t.Run(refusal+"/"+call, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := connected(t)
|
||||
refuse(h.google)
|
||||
if err := calls[call](h); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("%s = %v, want ErrReconnect", call, err)
|
||||
}
|
||||
if row, _ := h.row(t); row.RefreshTokenCt != nil || row.CalendarID.Valid {
|
||||
t.Fatalf("row = %+v, want the grant cleared and no calendar chosen", row)
|
||||
}
|
||||
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
|
||||
for again, do := range calls {
|
||||
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
|
||||
}
|
||||
}
|
||||
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
|
||||
t.Error("a call reached Google after the grant was refused")
|
||||
}
|
||||
|
||||
h.google.AcceptAccessToken()
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
|
||||
t.Fatalf("SelectCalendar after connecting again: %v", err)
|
||||
}
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
|
||||
t.Errorf("PushEvents after connecting again: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconnectAfterRefusal connects again after Google refused the grant:
|
||||
// the new token is stored, the reason cleared and the calendar kept.
|
||||
func TestReconnectAfterRefusal(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
h.google.RevokeGrant()
|
||||
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||||
}
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
st, err := h.svc.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State != gcal.StatusConnected || st.LastSyncError != nil || st.CalendarID == nil || *st.CalendarID != dinners.ID {
|
||||
t.Errorf("Status = %+v, want connected to %s with no error", st, dinners.ID)
|
||||
}
|
||||
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
|
||||
t.Errorf("PushEvents after connecting again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectAfterRefusal disconnects a connection whose grant Google
|
||||
// refused: there is nothing to revoke, and the connection is deleted.
|
||||
func TestDisconnectAfterRefusal(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
h.google.RevokeGrant()
|
||||
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if n := len(h.google.Revoked()); n != 0 {
|
||||
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("connection kept after disconnecting")
|
||||
}
|
||||
}
|
||||
|
||||
// transients are the failures Google asks clients to retry.
|
||||
var transients = []int{http.StatusServiceUnavailable, http.StatusInternalServerError, http.StatusTooManyRequests}
|
||||
|
||||
// TestTransientFailureIsRetried fails each Google request three times in a
|
||||
// row: it is tried three times (two retries), then fails as retryable, and
|
||||
// nothing is disconnected or cleared.
|
||||
func TestTransientFailureIsRetried(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range transients {
|
||||
for name, tc := range map[string]struct {
|
||||
fail func(*gcaltest.Google)
|
||||
do func(*harness) error
|
||||
tries func(*gcaltest.Google) int
|
||||
}{
|
||||
"token refresh": {
|
||||
fail: func(g *gcaltest.Google) { g.FailToken(status) },
|
||||
do: calls["list calendars"],
|
||||
tries: (*gcaltest.Google).TokenCalls,
|
||||
},
|
||||
"calendar list": {
|
||||
fail: func(g *gcaltest.Google) { g.FailCalendarList(status) },
|
||||
do: calls["choose a calendar"],
|
||||
tries: (*gcaltest.Google).CalendarCalls,
|
||||
},
|
||||
"event write": {
|
||||
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 3, status) },
|
||||
do: calls["push events"],
|
||||
tries: (*gcaltest.Google).EventRequests,
|
||||
},
|
||||
} {
|
||||
t.Run(name+"/"+http.StatusText(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
before, _ := h.row(t)
|
||||
tried := tc.tries(h.google)
|
||||
tc.fail(h.google)
|
||||
err := tc.do(h)
|
||||
var calendar *gcal.CalendarError
|
||||
if !errors.As(err, &calendar) || !calendar.Retryable() {
|
||||
t.Fatalf("err = %v, want a retryable *CalendarError", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if n := tc.tries(h.google) - tried; n != 3 {
|
||||
t.Errorf("tried %d times, want 3", n)
|
||||
}
|
||||
after, ok := h.row(t)
|
||||
if !ok || !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("connection = %+v, %v; want it unchanged %+v", after, ok, before)
|
||||
}
|
||||
if st, err := h.svc.Status(context.Background()); err != nil || st.State != gcal.StatusConnected {
|
||||
t.Errorf("Status = %+v, %v; want connected", st, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransientFailureRecovers fails each Google request twice: the second
|
||||
// retry succeeds.
|
||||
func TestTransientFailureRecovers(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, tc := range map[string]struct {
|
||||
fail func(*gcaltest.Google)
|
||||
do func(*harness) error
|
||||
}{
|
||||
"calendar list": {
|
||||
fail: func(g *gcaltest.Google) { g.FailCalendarListNext(2, http.StatusServiceUnavailable) },
|
||||
do: calls["choose a calendar"],
|
||||
},
|
||||
"event write": {
|
||||
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 2, http.StatusServiceUnavailable) },
|
||||
do: func(h *harness) error {
|
||||
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
tc.fail(h.google)
|
||||
if err := tc.do(h); err != nil {
|
||||
t.Fatalf("err = %v, want the second retry to succeed", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransientFailureBacksOff checks the waits between tries: the first
|
||||
// retry waits the retry delay, the second twice that.
|
||||
func TestTransientFailureBacksOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := chosen(t)
|
||||
gcal.SetRetryDelay(h.svc, 50*time.Millisecond)
|
||||
h.google.FailCalendarList(http.StatusServiceUnavailable)
|
||||
start := time.Now()
|
||||
if _, err := h.svc.Calendars(context.Background()); err == nil {
|
||||
t.Fatal("Calendars succeeded")
|
||||
}
|
||||
if took := time.Since(start); took < 150*time.Millisecond {
|
||||
t.Errorf("three tries took %s, want at least 150ms of waits", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
st, err := h.svc.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := statusJSON(t, st); got != `{"status":"not_connected","calendar_id":null,"calendar_name":null,"connected_at":null,"last_sync_error":null}` {
|
||||
t.Errorf("not connected: Status = %s", got)
|
||||
}
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err = h.svc.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := statusJSON(t, st); got != `{"status":"connected","calendar_id":null,"calendar_name":null,"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":null}` {
|
||||
t.Errorf("connected: Status = %s", got)
|
||||
}
|
||||
h.google.SetCalendars(0, dinners)
|
||||
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.RevokeGrant()
|
||||
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||||
}
|
||||
st, err = h.svc.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"status":"reconnect_required","calendar_id":"dinners@group.calendar.google.com","calendar_name":"Dinners",` +
|
||||
`"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":"Google access was revoked or expired — reconnect"}`
|
||||
if got := statusJSON(t, st); got != want {
|
||||
t.Errorf("reconnect required: Status =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func statusJSON(t *testing.T, st gcal.Status) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user