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