diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index 082b579..4e47b92 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -44,6 +44,10 @@ func loadConfig(t *testing.T, keyFile string) config.Config { if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil { t.Fatal(err) } + googleSecretPath := filepath.Join(dir, "google_client_secret") + if err := os.WriteFile(googleSecretPath, []byte("google-secret\n"), 0o600); err != nil { + t.Fatal(err) + } t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0") t.Setenv(config.EnvPublicURL, "http://localhost") t.Setenv(config.EnvDataDir, t.TempDir()) @@ -53,6 +57,8 @@ func loadConfig(t *testing.T, keyFile string) config.Config { t.Setenv(config.EnvOIDCClientID, "mealquest") t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath) t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin") + t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com") + t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath) // MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value. t.Setenv(config.EnvDefaultTZ, "") if err := os.Unsetenv(config.EnvDefaultTZ); err != nil { diff --git a/cmd/server/main.go b/cmd/server/main.go index f5e4efb..51b105b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -21,6 +21,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -113,7 +114,20 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger) + googleSvc, err := gcal.NewService(db, box, gcal.Config{ + ClientID: cfg.GoogleClientID, + ClientSecret: cfg.GoogleClientSecret.Reveal(), + PublicURL: cfg.PublicURL, + SecretKey: []byte(cfg.SecretKey.Reveal()), + Endpoint: gcal.GoogleEndpoint, + RevokeURL: gcal.GoogleRevokeURL, + CalendarAPI: gcal.GoogleCalendarAPI, + }) + if err != nil { + return err + } + + planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger) if err != nil { return err } @@ -134,7 +148,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/deploy/.env.example b/deploy/.env.example index a908b3f..6262452 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -8,6 +8,7 @@ # mkdir -p deploy/secrets && chmod 700 deploy/secrets # head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key # printf '%s\n' '' > deploy/secrets/oidc_client_secret +# printf '%s\n' '' > deploy/secrets/google_client_secret # sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/* # # SearXNG's secret key is the exception: compose reads it itself, so it @@ -54,6 +55,14 @@ MEALQUEST_OIDC_CLIENT_ID=mealquest # Comma-separated Authentik groups whose members may sign in. MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin +# --- Google Calendar (Push to Calendar) ------------------------------------ + +# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google +# Calendar OAuth client"). Required: the service will not start without it +# and the client secret file. Its authorized redirect URI is +# /api/v1/integrations/google/callback. +MEALQUEST_GOOGLE_CLIENT_ID=.apps.googleusercontent.com + # Optional session lifetimes. A session ends after IDLE unused and always MAX # after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h. MEALQUEST_SESSION_IDLE=720h @@ -67,6 +76,8 @@ MEALQUEST_SESSION_MAX=4320h # Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM). # MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret # The Authentik provider's client secret. +# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret +# The Google OAuth web client's secret. # MEALQUEST_SEARXNG_URL=http://searxng:8080 # The SearXNG recipe-search sidecar on the compose network. The Settings # screen pre-fills it as the SearXNG URL until one is saved; the saved @@ -81,7 +92,3 @@ MEALQUEST_SESSION_MAX=4320h # MEALQUEST_VAPID_PUBLIC_KEY= # MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key # MEALQUEST_VAPID_SUBJECT=mailto: -# -# Google Calendar OAuth web client (Feature 16). -# MEALQUEST_GOOGLE_CLIENT_ID=.apps.googleusercontent.com -# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index edfc375..61137ae 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -33,6 +33,8 @@ On office-srvr (10.60.1.14), as recorded on 2026-10-05: Elsewhere: - Admin access to Authentik at . +- Owner access to the Google Cloud project that holds MealQuest's OAuth + client (see "Google Calendar OAuth client"). - SSH access to the Traefik host 10.60.1.33. - Cloudflare access for the `cubecraftlabs.com` zone, and admin access to both Pi-holes (10.60.1.101 `dns.ccl`, 10.60.1.85 `dns2.ccl`). @@ -129,6 +131,7 @@ Values in use: | `MEALQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/mealquest/` | | `MEALQUEST_OIDC_CLIENT_ID` | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret) | | `MEALQUEST_OIDC_ALLOWED_GROUPS` | `mealquest-admin` | +| `MEALQUEST_GOOGLE_CLIENT_ID` | Client ID of the Google OAuth web client (not a secret; see "Google Calendar OAuth client") | | `MEALQUEST_SESSION_IDLE` / `MEALQUEST_SESSION_MAX` | `720h` / `4320h` | `MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR` and the `*_FILE` variables are @@ -146,6 +149,7 @@ service refuses to start. | --- | --- | --- | | `secret_key` | base64 of 32 random bytes; encrypts stored secrets (AES-256-GCM) | `/run/secrets/secret_key` | | `oidc_client_secret` | Client Secret of the Authentik provider `mealquest` | `/run/secrets/oidc_client_secret` | +| `google_client_secret` | Client secret of the Google OAuth web client | `/run/secrets/google_client_secret` | | `litestream_key` | dedicated SSH private key for the NAS (see "Backups") | `/etc/litestream/sftp_key` | | `searxng.env` | `SEARXNG_SECRET=<64 hex characters>`; SearXNG's secret key | not mounted; compose passes it as an environment variable | @@ -156,11 +160,12 @@ it (as `env_file`), so it is owned by `overseer`, mode 0600. mkdir -p deploy/secrets && chmod 700 deploy/secrets head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key printf '%s\n' '' > deploy/secrets/oidc_client_secret +printf '%s\n' '' > deploy/secrets/google_client_secret # litestream_key: see "Backups" > "Database — Litestream" -sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/litestream_key +sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/google_client_secret deploy/secrets/litestream_key sudo chmod 600 deploy/secrets/* (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env) -sudo ls -la deploy/secrets # three files mealquest mealquest, searxng.env overseer overseer; all -rw------- +sudo ls -la deploy/secrets # four files mealquest mealquest, searxng.env overseer overseer; all -rw------- ``` The `mealquest` host user (uid/gid 10001) is created in "Media — nightly @@ -238,6 +243,38 @@ On 2026-10-05 discovery returned 200 with issuer `https://auth.cubecraftlabs.com/application/o/mealquest/` and `id_token_signing_alg_values_supported: ["RS256"]`. +## Google Calendar OAuth client + +Push to Calendar connects the household's Google account with a Google +OAuth web client. MealQuest calls Google's OAuth and Calendar REST +endpoints directly. **The service will not start without +`MEALQUEST_GOOGLE_CLIENT_ID` and `deploy/secrets/google_client_secret`**, +so create the client before deploying a release that contains Feature 16. + +In the Google Cloud console, in the project that holds MealQuest: + +| Object | Setting | +| --- | --- | +| API | Google Calendar API enabled | +| OAuth consent screen | User type External; app name MealQuest; publishing status **In production** (a client left in Testing gets refresh tokens that expire after 7 days, so Push to Calendar would stop working a week after connecting) | +| Scopes | `https://www.googleapis.com/auth/calendar.events` and `https://www.googleapis.com/auth/calendar.calendarlist.readonly` | +| Credentials | OAuth client ID, application type **Web application**, name `mealquest` | +| Authorized redirect URI | `https://mealquest.cubecraftlabs.com/api/v1/integrations/google/callback` (exactly; it is `MEALQUEST_PUBLIC_URL`'s origin plus that path) | +| Client ID | in `deploy/.env` as `MEALQUEST_GOOGLE_CLIENT_ID` (not a secret) | +| Client secret | in `deploy/secrets/google_client_secret` only | + +The app is unverified, so Google shows an "unverified app" warning on the +consent screen; choose to continue. That is expected for a single-household +deployment. + +To connect, a signed-in household member opens +`https://mealquest.cubecraftlabs.com/api/v1/integrations/google/connect` +(the Settings screen links there once it ships). MealQuest asks for offline +access with consent always prompted, and lands back on +`/settings/calendar` once the refresh token is stored. Disconnecting +(`DELETE /api/v1/integrations/google`) revokes the token at Google and +forgets it. + ## Reverse proxy (Traefik) MealQuest is published at by the @@ -1313,6 +1350,23 @@ Sign in at to confirm; sign-ins fail between regenerating the secret in Authentik and recreating the service. +### Google OAuth client secret (`deploy/secrets/google_client_secret`) + +In the Google Cloud console, open the OAuth client `mealquest`, add a new +client secret, write it to the file (same owner and mode as above) and +recreate the service: + +```sh +printf '%s\n' '' | sudo tee deploy/secrets/google_client_secret >/dev/null +sudo chown 10001:10001 deploy/secrets/google_client_secret +sudo chmod 600 deploy/secrets/google_client_secret +docker compose -f deploy/compose.yaml up -d --force-recreate mealquest +``` + +Then disable and delete the old secret in the console. A new client secret +leaves the household's stored refresh token valid, so nothing needs +reconnecting. + ### LLM API key Stored encrypted in the database, not in `deploy/`. In the app, open diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 4fe042a..f54b159 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -32,10 +32,12 @@ services: MEALQUEST_DATA_DIR: /data MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret + MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret MEALQUEST_SEARXNG_URL: http://searxng:8080 secrets: - secret_key - oidc_client_secret + - google_client_secret volumes: - mealquest-data:/data ports: @@ -128,6 +130,8 @@ secrets: file: ./secrets/secret_key oidc_client_secret: file: ./secrets/oidc_client_secret + google_client_secret: + file: ./secrets/google_client_secret volumes: mealquest-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d9cd7d..d8b64d0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,7 +100,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `suggestion_batches` | trigger, status, ai_run_id. | | `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | -| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | +| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | @@ -350,8 +350,9 @@ automatic** (D-021). 409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular and every Quest log dinner no other plan for the current or a later week has, with the household context and last week's dinners, and stores the - week: a new plan is `draft`; a redraft keeps each day's - `calendar_event_id`, and a `synced` or `changed` plan becomes `changed`. + week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of + each day whose dinner stays the same and clears it for the others; a + `synced` or `changed` plan becomes `changed`. Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else 409 `confirm_required`; a plan synced while an unconfirmed draft runs is left as it is and the job fails `confirm_required`. The job reads the @@ -366,8 +367,8 @@ automatic** (D-021). dinners fails the job `not_enough_dinners`, draft_week failing `ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes `{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes - `is_new`, moves a `synced` plan to `changed`, and answers 200 with the - plan. A date outside the week is 400 `invalid_date`, an unknown dinner or + `is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to + `changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and a dinner already on another day of the week 409 `dinner_already_planned`. - Rotation check (`plan.Service`, D-021). This release provides the @@ -618,8 +619,128 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. not come back. - Connected once with Google OAuth in Settings; one event per planned dinner. -- **Deterministic event ids**; each plan day records its - `calendar_event_id`. +- `internal/gcal` owns the connection, as a confidential OAuth web client + (`MEALQUEST_GOOGLE_CLIENT_ID`, `MEALQUEST_GOOGLE_CLIENT_SECRET_FILE`, both + required at boot) over plain REST — no Google SDK. Every route is under + the signed-in `/api/v1`: + - `GET /api/v1/integrations/google/connect` redirects to Google's consent + screen with scopes `calendar.events` and `calendar.calendarlist.readonly`, + `access_type=offline`, `prompt=consent` and PKCE. Its `state` travels in + the signed `__Host-mealquest_google` flow cookie (the same + `auth.FlowCookie` mechanism as the login cookie), bound to the session by + a hash of the session's CSRF token. The service also keeps each started + connect's `state` in memory until the cookie expires; the callback + consumes it, so a replayed cookie is refused (`invalid_state`), and a + restart mid-connect means connecting again. + - `GET /api/v1/integrations/google/callback` (the redirect URI: + `MEALQUEST_PUBLIC_URL`'s origin plus this path) checks the state and + session, exchanges the code, refuses a response with no refresh token + (`reconnect_required`) or without both scopes, seals the refresh token + with `secretbox` into `integrations_google.refresh_token_ct`, sets + `connected_at` and redirects to `/settings/calendar`. A reconnect keeps + the chosen calendar and clears `last_sync_error`. + - `GET /api/v1/integrations/google` answers `{status, calendar_id, + calendar_name, connected_at, last_sync_error}`, where `status` is + `not_connected`, `connected` or `reconnect_required` (connected, but + Google refused the grant and its refresh token was cleared); absent + values are `null`. It never holds a token. + - `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at + Google, then deletes the row and, in the same transaction, forgets every + pushed event (see Push to Calendar); a token Google reports as + `invalid_token` counts as revoked, and any other revoke failure keeps + the connection and its pushed events. A connection that requires a + reconnect has no token to revoke and is deleted the same way. + - `GET /api/v1/integrations/google/calendars` trades the stored refresh + token for an access token and reads every page of the Calendar API's + `users/me/calendarList`, answering `[{id, summary, primary, + accessRole}]` for the calendars whose `accessRole` is `writer` or + `owner`, in Google's order. Nothing else from Google's response is + passed on. + - `PUT /api/v1/integrations/google/calendar` `{calendar_id}` + (CSRF-checked) lists the calendars the same way and stores + `calendar_id` and `calendar_name` (the calendar's `summary`) only when + the id is among them, answering `{calendar_id, calendar_name}`; any + other id is 422 `unknown_calendar` and leaves the stored choice as it + was. A calendar chosen earlier that has since been deleted or become + read-only drops out of the listing and cannot be chosen again; the + stored choice stays until the household chooses another. + - Listing and choosing answer 409 `not_connected` when nothing is + connected, and fail on Google as every Calendar call does (below). + - **Token failures**, the same for listing, choosing and pushing: + - When Google refuses the grant — `invalid_grant` on the token + refresh, or HTTP 401 from the Calendar API to the access token just + refreshed — the connection becomes reconnect required, under the + account lock: `refresh_token_ct` is cleared and `last_sync_error` + set to "Google access was revoked or expired — reconnect", and the + call answers 409 `google_reconnect_required`. Later calls answer the + same without contacting Google, until a reconnect stores a new token + (and clears `last_sync_error`) or a disconnect deletes the row. The + chosen calendar and every stored `calendar_event_id` are kept: the + pushed events are still on the chosen calendar, so the invariant + below holds, and the first push after reconnecting updates them in + place. + - HTTP 429 and 5xx from the token endpoint or the Calendar API are + retried at most twice (three tries), waiting 0.5 s then 1 s, still + under the account lock. If the last try fails the same way the call + answers 503 `google_unavailable` with `retryable: true`; nothing is + cleared or disconnected. An event insert retried after Google took it + is answered 409 and updated, so a retry never adds a second event. + - Any other Google failure is 502 `google_failed`. + - Connect's exchange-and-store, disconnect's read-revoke-delete and + every use of the stored token (listing and choosing calendars) hold one + lock across the Google requests and the database write: Google revokes + the whole grant, so a revoke overlapping a reconnect would otherwise + leave a stored token Google has already revoked. + - No response, log line or error ever holds a token or Google's response + text. +- Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar` + (CSRF-checked, `plan.Service.Push` writing through + `gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day + is one event in the chosen calendar: summary `Dinner: {name}`, from the + household `dinner_time` on that date for 60 minutes, as `dateTime` with + the household `timeZone` (so its UTC instant follows a DST change within + the week), and a description of the dinner's name, `Start by {time}` for + a crock-pot dinner of known minutes, and + `{MEALQUEST_PUBLIC_URL}/dinner/{id}`. + - **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of + `mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`, + within Google's id rule. Each event is inserted; a 409 (the id exists, + even as an event deleted in Calendar) updates it instead, with status + `confirmed`, so pushing again never adds a second event. + - A push writes the days whose `calendar_event_id` is empty (never + pushed, swapped, or redrafted to another dinner). When none is, it + writes every day again, which puts back anything changed in Calendar. + - A stored `calendar_event_id` is always an event on the chosen calendar. + Choosing a calendar other than the stored one, and disconnecting (which + leaves none chosen; connecting again chooses none either), clear every + day's `calendar_event_id` in the same transaction as the choice or the + delete, and edit each plan that had one (revision raised, `synced` → + `changed`), so the next push writes the whole week to the calendar then + chosen and a push that read such a plan before is refused (409 + `plan_changed`). A reconnect while connected keeps the chosen calendar, + so it keeps the event ids. Nothing else clears the choice: a grant + Google refuses clears only the stored token (see Token failures), so + the event ids stay on the calendar still chosen. + - Only a push that wrote all its events is recorded, in one transaction: + the plan becomes `synced` with `calendar_synced_at`, and each day + written stores its event id. A push that failed part way records + nothing and leaves the status as it was; pushing again updates the + events already written. The record runs under the account lock, in the + same hold as the event writes, so the chosen calendar cannot change + between them. A plan swapped or redrafted while it was being + pushed, or whose events another calendar choice cleared (its revision + moved), is not recorded: 409 `plan_changed`. + - Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar + chosen), 409 `calendar_unavailable` (the chosen calendar was deleted — + Google 404 — or is no longer writable — 403 `requiredAccessLevel`; + choose another), 409 `google_reconnect_required`, 503 + `google_unavailable` (retryable) and 502 `google_failed` (see Token + failures), 404 when the week + has no plan, 400 `invalid_week`. The push holds the account lock + across its Google requests and its record, like listing and choosing. + - The week's Supply run (Feature 17) is made from a pushed plan: a + successful `Push` returns that plan, which is where Feature 17 hooks + in. Nothing here generates a shopping list. ## Frontend diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d227ad8..2000308 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -107,7 +107,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger return nil, err } - client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect} + client := &http.Client{Timeout: providerTimeout, CheckRedirect: CheckRedirect} discovery := *client recorder := &statusRecorder{next: http.DefaultTransport} discovery.Transport = recorder @@ -205,11 +205,12 @@ func checkEndpoint(name, raw string) error { // maxRedirects matches net/http's default redirect limit. const maxRedirects = 10 -// checkRedirect is the provider client's redirect policy. On a 307 or 308 to -// the same host Go resends the body and Authorization header, which carry the -// code and client secret, so every target must pass secureurl.Check and an -// https:// request never follows a redirect to plain http://, even loopback. -func checkRedirect(req *http.Request, via []*http.Request) error { +// CheckRedirect is the redirect policy of every client that talks to an +// OAuth provider. On a 307 or 308 to the same host Go resends the body and +// Authorization header, which carry the code and client secret, so every +// target must pass secureurl.Check and an https:// request never follows a +// redirect to plain http://, even loopback. +func CheckRedirect(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } @@ -350,7 +351,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code, oauth2.VerifierOption(state.Verifier)) if err != nil { - return Claims{}, exchangeError(err) + return Claims{}, ExchangeError(err) } raw, ok := token.Extra("id_token").(string) if !ok || raw == "" { @@ -400,7 +401,7 @@ func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject } // userinfoError reduces a failed userinfo request to a fixed category, like -// exchangeError: go-oidc's error text can carry the response body. +// ExchangeError: go-oidc's error text can carry the response body. func userinfoError(err error) error { var transport *url.Error if errors.As(err, &transport) { @@ -425,10 +426,11 @@ func (a *Auth) member(groups []string) bool { return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) }) } -// exchangeError reduces a failed code exchange to a fixed category and safe -// metadata. The oauth2 error text can carry the token endpoint's response -// body or error_description, which may echo credentials, so it is dropped. -func exchangeError(err error) error { +// ExchangeError reduces a failed OAuth code exchange to a fixed category and +// safe metadata. The oauth2 error text can carry the token endpoint's +// response body or error_description, which may echo credentials, so it is +// dropped. +func ExchangeError(err error) error { var retrieve *oauth2.RetrieveError var transport *url.Error switch { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 407bbb8..af9f53e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1060,7 +1060,7 @@ func TestCheckRedirect(t *testing.T) { {"https://auth.example.com/token", "ftp://auth.example.com/token", false}, } for _, tc := range tests { - err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)}) + err := CheckRedirect(request(tc.to), []*http.Request{request(tc.from)}) if (err == nil) != tc.ok { t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok) } @@ -1069,7 +1069,7 @@ func TestCheckRedirect(t *testing.T) { for i := range via { via[i] = request("https://auth.example.com/token") } - if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil { + if err := CheckRedirect(request("https://auth.example.com/token"), via); err == nil { t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects) } } diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go index 4be2d7d..ed85350 100644 --- a/internal/auth/cookie.go +++ b/internal/auth/cookie.go @@ -27,38 +27,86 @@ type loginState struct { Nonce string `json:"n"` Verifier string `json:"v"` Return string `json:"r"` - Expires int64 `json:"e"` // Unix seconds + FlowExpiry } -// loginCookie signs and verifies the login cookie with HMAC-SHA256. +// loginCookie is the FlowCookie carrying loginState. type loginCookie struct { - key []byte + flow FlowCookie } func newLoginCookie(secretKey []byte) loginCookie { - mac := hmac.New(sha256.New, secretKey) - mac.Write([]byte(loginCookieKeyLabel)) - return loginCookie{key: mac.Sum(nil)} + return loginCookie{flow: NewFlowCookie(loginCookieName, loginCookieKeyLabel, loginCookieTTL, secretKey)} } -func (c loginCookie) sign(payload string) string { +func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error { + return c.flow.Set(w, &s, now) +} + +func (c loginCookie) clear(w http.ResponseWriter) { c.flow.Clear(w) } + +func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) { + var s loginState + if err := c.flow.Read(r, &s, now); err != nil { + return loginState{}, err + } + return s, nil +} + +// FlowExpiry is embedded in every value a FlowCookie carries; Set fills it +// and Read enforces it. +type FlowExpiry struct { + Expires int64 `json:"e"` // Unix seconds +} + +func (e *FlowExpiry) flowExpiry() *FlowExpiry { return e } + +// flowValue is a pointer to a struct that embeds FlowExpiry. +type flowValue interface { + flowExpiry() *FlowExpiry +} + +// FlowCookie carries a redirect flow's state — the OAuth state, PKCE +// verifier and the like — from the request that sends the browser to a +// provider to the callback it returns to. The value is JSON, signed with +// HMAC-SHA256 under a key derived from the server secret and the cookie's +// own label, and expires after a fixed time. It is not encrypted: it must +// not hold anything the browser may not see. +type FlowCookie struct { + name string + ttl time.Duration + key []byte +} + +// NewFlowCookie returns the flow cookie name, valid for ttl, signed under a +// key derived from secretKey and keyLabel. Each flow uses its own keyLabel so +// one flow's cookie never verifies as another's. name must carry the __Host- +// prefix: the cookie is Secure, host-only and Path=/. +func NewFlowCookie(name, keyLabel string, ttl time.Duration, secretKey []byte) FlowCookie { + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(keyLabel)) + return FlowCookie{name: name, ttl: ttl, key: mac.Sum(nil)} +} + +func (c FlowCookie) sign(payload string) string { mac := hmac.New(sha256.New, c.key) mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } -func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error { - s.Expires = now.Add(loginCookieTTL).Unix() - data, err := json.Marshal(s) +// Set stores v, which expires ttl after now, in the cookie. +func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error { + v.flowExpiry().Expires = now.Add(c.ttl).Unix() + data, err := json.Marshal(v) if err != nil { return err } payload := base64.RawURLEncoding.EncodeToString(data) http.SetCookie(w, &http.Cookie{ - Name: loginCookieName, + Name: c.name, Value: payload + "." + c.sign(payload), Path: "/", - MaxAge: int(loginCookieTTL / time.Second), + MaxAge: int(c.ttl / time.Second), Secure: true, HttpOnly: true, // Lax so the cookie rides the provider's top-level redirect back. @@ -67,9 +115,12 @@ func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) err return nil } -func (c loginCookie) clear(w http.ResponseWriter) { +// Clear asks the browser to discard the cookie. A copy of it still verifies +// until it expires, so a flow that must be single-use also consumes its +// state on the server. +func (c FlowCookie) Clear(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ - Name: loginCookieName, + Name: c.name, Path: "/", MaxAge: -1, Secure: true, @@ -78,27 +129,28 @@ func (c loginCookie) clear(w http.ResponseWriter) { }) } -func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) { - cookie, err := r.Cookie(loginCookieName) +// Read verifies the cookie r carries and decodes it into v. It fails when +// the cookie is missing, its signature is invalid or it has expired at now. +func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error { + cookie, err := r.Cookie(c.name) if err != nil { - return loginState{}, errors.New("login cookie missing") + return fmt.Errorf("%s cookie missing", c.name) } payload, sig, ok := strings.Cut(cookie.Value, ".") if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) { - return loginState{}, errors.New("login cookie signature invalid") + return fmt.Errorf("%s cookie signature invalid", c.name) } data, err := base64.RawURLEncoding.DecodeString(payload) if err != nil { - return loginState{}, fmt.Errorf("login cookie encoding: %w", err) + return fmt.Errorf("%s cookie encoding: %w", c.name, err) } - var s loginState - if err := json.Unmarshal(data, &s); err != nil { - return loginState{}, fmt.Errorf("login cookie payload: %w", err) + if err := json.Unmarshal(data, v); err != nil { + return fmt.Errorf("%s cookie payload: %w", c.name, err) } - if now.Unix() >= s.Expires { - return loginState{}, errors.New("login cookie expired") + if now.Unix() >= v.flowExpiry().Expires { + return fmt.Errorf("%s cookie expired", c.name) } - return s, nil + return nil } // returnPath validates the optional post-login return path. Empty means "/". diff --git a/internal/config/config.go b/internal/config/config.go index c838255..ff2ecce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,9 @@ const ( EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE" EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS" + EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID" + EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE" + EnvSessionIdle = "MEALQUEST_SESSION_IDLE" EnvSessionMax = "MEALQUEST_SESSION_MAX" @@ -61,6 +64,12 @@ type Config struct { // OIDCAllowedGroups lists the groups whose members may sign in. OIDCAllowedGroups []string + // GoogleClientID and GoogleClientSecret are the Google OAuth web client + // Push to Calendar connects with. Its redirect URI is PublicURL's origin + // followed by /api/v1/integrations/google/callback. + GoogleClientID string + GoogleClientSecret Secret + // SessionIdle ends a session left unused this long. SessionIdle time.Duration // SessionMax ends every session this long after sign-in, used or not. @@ -110,6 +119,9 @@ func load(lookup func(string) (string, bool)) (Config, error) { OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile), OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups), + GoogleClientID: r.str(EnvGoogleClientID), + GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile), + SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 91e24c7..297bac6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -29,6 +29,10 @@ func TestLoad(t *testing.T) { if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil { t.Fatal(err) } + googleSecretPath := filepath.Join(dir, "google_client_secret") + if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil { + t.Fatal(err) + } emptyPath := filepath.Join(dir, "empty_secret") if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil { t.Fatal(err) @@ -46,6 +50,9 @@ func TestLoad(t *testing.T) { EnvOIDCClientID: "mealquest", EnvOIDCClientSecretFile: clientSecretPath, EnvOIDCAllowedGroups: "mealquest-admin", + + EnvGoogleClientID: "mealquest.apps.googleusercontent.com", + EnvGoogleClientSecretFile: googleSecretPath, } } with := func(changes map[string]string, unset ...string) map[string]string { @@ -94,6 +101,7 @@ func TestLoad(t *testing.T) { badVars: []string{ EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile, EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups, + EnvGoogleClientID, EnvGoogleClientSecretFile, }, }, { @@ -211,6 +219,31 @@ func TestLoad(t *testing.T) { env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}), badVars: []string{"MEALQUEST_SECRET_KEY"}, }, + { + name: "Google client ID missing", + env: with(nil, EnvGoogleClientID), + badVars: []string{EnvGoogleClientID}, + }, + { + name: "Google client secret file missing", + env: with(nil, EnvGoogleClientSecretFile), + badVars: []string{EnvGoogleClientSecretFile}, + }, + { + name: "Google client secret file unreadable", + env: with(map[string]string{EnvGoogleClientSecretFile: unreadablePath}), + badVars: []string{EnvGoogleClientSecretFile}, + }, + { + name: "Google client secret file empty", + env: with(map[string]string{EnvGoogleClientSecretFile: emptyPath}), + badVars: []string{EnvGoogleClientSecretFile}, + }, + { + name: "inline Google client secret rejected", + env: with(map[string]string{"MEALQUEST_GOOGLE_CLIENT_SECRET": "inline"}), + badVars: []string{"MEALQUEST_GOOGLE_CLIENT_SECRET"}, + }, { name: "inline OIDC client secret rejected", env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}), @@ -409,6 +442,10 @@ func TestLoadValues(t *testing.T) { if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil { t.Fatal(err) } + googleSecretPath := filepath.Join(dir, "google_client_secret") + if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil { + t.Fatal(err) + } env := map[string]string{ EnvHTTPAddr: "127.0.0.1:9000", EnvPublicURL: "http://localhost:9000", @@ -421,6 +458,9 @@ func TestLoadValues(t *testing.T) { EnvOIDCClientSecretFile: clientSecretPath, EnvOIDCAllowedGroups: " mealquest-admin ,family", + EnvGoogleClientID: "mealquest.apps.googleusercontent.com", + EnvGoogleClientSecretFile: googleSecretPath, + EnvDefaultTZ: "Europe/London", EnvSearXNGURL: "http://searxng:8080", @@ -454,6 +494,12 @@ func TestLoadValues(t *testing.T) { if cfg.OIDCClientSecret.Reveal() != "client-s3cret" { t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal()) } + if cfg.GoogleClientID != "mealquest.apps.googleusercontent.com" { + t.Errorf("GoogleClientID = %q", cfg.GoogleClientID) + } + if cfg.GoogleClientSecret.Reveal() != "google-s3cret" { + t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal()) + } if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) { t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want) } @@ -492,7 +538,7 @@ func TestSecretRedacted(t *testing.T) { s := Secret{value: "s3cret-value"} for _, format := range []string{"%v", "%s", "%+v", "%#v"} { - cfg := Config{SecretKey: s, OIDCClientSecret: s} + cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s} for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} { if strings.Contains(out, "s3cret-value") { t.Errorf("format %s leaked secret: %s", format, out) diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go index bf97ec5..b429e30 100644 --- a/internal/db/plans.sql.go +++ b/internal/db/plans.sql.go @@ -10,6 +10,18 @@ import ( "database/sql" ) +const clearCalendarEvents = `-- name: ClearCalendarEvents :exec +UPDATE plan_days +SET calendar_event_id = NULL, updated_at = ? +WHERE calendar_event_id IS NOT NULL +` + +// Clears every day's calendar event id: none is on the chosen calendar. +func (q *Queries) ClearCalendarEvents(ctx context.Context, updatedAt string) error { + _, err := q.db.ExecContext(ctx, clearCalendarEvents, updatedAt) + return err +} + const createWeekPlan = `-- name: CreateWeekPlan :exec INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at) VALUES (?, ?, 'draft', ?, ?, ?) @@ -154,7 +166,7 @@ func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([ } const listPlanDays = `-- name: ListPlanDays :many -SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision +SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision FROM plan_days JOIN dinners ON dinners.id = plan_days.dinner_id WHERE plan_days.plan_id = ? @@ -162,9 +174,10 @@ ORDER BY plan_days.date ` type ListPlanDaysRow struct { - Date string - IsNew int64 - Dinner Dinner + Date string + IsNew int64 + CalendarEventID sql.NullString + Dinner Dinner } // The days of plan plan_id with their dinners, Monday first. A day with @@ -181,6 +194,7 @@ func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDa if err := rows.Scan( &i.Date, &i.IsNew, + &i.CalendarEventID, &i.Dinner.ID, &i.Dinner.Name, &i.Dinner.NormalizedName, @@ -247,6 +261,26 @@ func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ( return items, nil } +const markCalendarPlansEdited = `-- name: MarkCalendarPlansEdited :exec +UPDATE week_plans +SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, + revision = revision + 1, + updated_at = ? +WHERE EXISTS ( + SELECT 1 FROM plan_days + WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL +) +` + +// Records that Calendar no longer shows the days with a calendar event id +// as planned, since another calendar or none is chosen: every plan with +// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes +// changed and a push that read it before is refused. +func (q *Queries) MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error { + _, err := q.db.ExecContext(ctx, markCalendarPlansEdited, updatedAt) + return err +} + const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec UPDATE week_plans SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, @@ -267,11 +301,42 @@ func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEdited return err } +const markWeekPlanSynced = `-- name: MarkWeekPlanSynced :execrows +UPDATE week_plans +SET status = 'synced', calendar_synced_at = ?, updated_at = ? +WHERE id = ? AND revision = ? +` + +type MarkWeekPlanSyncedParams struct { + CalendarSyncedAt sql.NullString + UpdatedAt string + ID string + Revision int64 +} + +// Records a Push to Calendar of plan id as it was at revision: the plan +// becomes synced. A plan edited since (another revision) is left as it is +// (0 rows). +func (q *Queries) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markWeekPlanSynced, + arg.CalendarSyncedAt, + arg.UpdatedAt, + arg.ID, + arg.Revision, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const putPlanDay = `-- name: PutPlanDay :exec INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (plan_id, date) DO UPDATE -SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at +SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, + calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END, + updated_at = excluded.updated_at ` type PutPlanDayParams struct { @@ -284,8 +349,9 @@ type PutPlanDayParams struct { UpdatedAt string } -// Plans dinner_id on date of plan_id, keeping the day's calendar event id -// when the day exists. +// Plans dinner_id on date of plan_id. An existing day keeps its calendar +// event id while its dinner stays the same; a new dinner clears it, since +// Calendar no longer shows the day as planned. func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error { _, err := q.db.ExecContext(ctx, putPlanDay, arg.ID, @@ -324,9 +390,37 @@ func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams return err } +const setPlanDayCalendarEvent = `-- name: SetPlanDayCalendarEvent :execrows +UPDATE plan_days +SET calendar_event_id = ?, updated_at = ? +WHERE plan_id = ? AND date = ? +` + +type SetPlanDayCalendarEventParams struct { + CalendarEventID sql.NullString + UpdatedAt string + PlanID string + Date string +} + +// Records that the event calendar_event_id on Calendar shows date of +// plan_id as planned. +func (q *Queries) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setPlanDayCalendarEvent, + arg.CalendarEventID, + arg.UpdatedAt, + arg.PlanID, + arg.Date, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const swapPlanDay = `-- name: SwapPlanDay :execrows UPDATE plan_days -SET dinner_id = ?, is_new = ?, updated_at = ? +SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ? WHERE plan_id = ? AND date = ? ` @@ -338,8 +432,9 @@ type SwapPlanDayParams struct { Date string } -// Plans dinner_id on an existing day of plan_id; a date the plan does not -// have is left as it is (0 rows). +// Plans dinner_id on an existing day of plan_id, clearing its calendar +// event id since Calendar no longer shows the day as planned; a date the +// plan does not have is left as it is (0 rows). func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) { result, err := q.db.ExecContext(ctx, swapPlanDay, arg.DinnerID, diff --git a/internal/db/querier.go b/internal/db/querier.go index 88053ef..0178e6d 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -15,6 +15,12 @@ type Querier interface { // Marks the oldest queued job running and returns it. Returns sql.ErrNoRows // when no job is queued. ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) + // Clears every day's calendar event id: none is on the chosen calendar. + ClearCalendarEvents(ctx context.Context, updatedAt string) error + // Records that Google refuses the stored grant: clears the refresh token and + // sets last_sync_error, keeping the chosen calendar. Affects no row when + // nothing is connected. + ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) // Drops references to the runs created before the cutoff, so the purge // can delete them. ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error) @@ -50,6 +56,7 @@ type Querier interface { // decided is left as it is (0 rows). DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error) + DeleteIntegrationsGoogle(ctx context.Context) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error FailJob(ctx context.Context, arg FailJobParams) (int64, error) @@ -169,12 +176,21 @@ type Querier interface { // The ids of the dinners planned in the week starting week_start, Monday // first. ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) + // Records that Calendar no longer shows the days with a calendar event id + // as planned, since another calendar or none is chosen: every plan with + // such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes + // changed and a push that read it before is refused. + MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error // Moves a dinner still in the Quest log to tried; a dinner with any other // status is left as it is (0 rows). MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error + // Records a Push to Calendar of plan id as it was at revision: the plan + // becomes synced. A plan edited since (another revision) is left as it is + // (0 rows). + MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) // Moves a dinner still suggested to status (quest_log or archived), // recording updated_by; a dinner no longer suggested is left as it is // (0 rows). @@ -185,20 +201,27 @@ type Querier interface { MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) // Stores a partial result of a running job. PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) - // Plans dinner_id on date of plan_id, keeping the day's calendar event id - // when the day exists. + // Plans dinner_id on date of plan_id. An existing day keeps its calendar + // event id while its dinner stays the same; a new dinner clears it, since + // Calendar no longer shows the day as planned. PutPlanDay(ctx context.Context, arg PutPlanDayParams) error // Records a new draft over plan id: its status and note, raising its // revision. RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + // Sets the chosen calendar; affects no row when nothing is connected. + SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) + // Records that the event calendar_event_id on Calendar shows date of + // plan_id as planned. + SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) // Marks a pending batch running; a batch in any other status is left as // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) - // Plans dinner_id on an existing day of plan_id; a date the plan does not - // have is left as it is (0 rows). + // Plans dinner_id on an existing day of plan_id, clearing its calendar + // event id since Calendar no longer shows the day as planned; a date the + // plan does not have is left as it is (0 rows). SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) // An unknown id returns sql.ErrNoRows. ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 50a036e..a68719b 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -10,6 +10,38 @@ import ( "database/sql" ) +const clearIntegrationsGoogleToken = `-- name: ClearIntegrationsGoogleToken :execrows +UPDATE integrations_google +SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ? +WHERE id = 1 +` + +type ClearIntegrationsGoogleTokenParams struct { + LastSyncError sql.NullString + UpdatedAt string +} + +// Records that Google refuses the stored grant: clears the refresh token and +// sets last_sync_error, keeping the chosen calendar. Affects no row when +// nothing is connected. +func (q *Queries) ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) { + result, err := q.db.ExecContext(ctx, clearIntegrationsGoogleToken, arg.LastSyncError, arg.UpdatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec +DELETE FROM integrations_google +WHERE id = 1 +` + +func (q *Queries) DeleteIntegrationsGoogle(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteIntegrationsGoogle) + return err +} + const getAISettings = `-- name: GetAISettings :one SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1 @@ -129,6 +161,27 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett return i, err } +const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows +UPDATE integrations_google +SET calendar_id = ?, calendar_name = ?, updated_at = ? +WHERE id = 1 +` + +type SetIntegrationsGoogleCalendarParams struct { + CalendarID sql.NullString + CalendarName sql.NullString + UpdatedAt string +} + +// Sets the chosen calendar; affects no row when nothing is connected. +func (q *Queries) SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setIntegrationsGoogleCalendar, arg.CalendarID, arg.CalendarName, arg.UpdatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const upsertAISettings = `-- name: UpsertAISettings :one INSERT INTO ai_settings ( id, provider, base_url, model, temperature, system_prompt, api_key_ct, diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go new file mode 100644 index 0000000..b516ce0 --- /dev/null +++ b/internal/gcal/calendars.go @@ -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 +} diff --git a/internal/gcal/calendars_test.go b/internal/gcal/calendars_test.go new file mode 100644 index 0000000..e2cece6 --- /dev/null +++ b/internal/gcal/calendars_test.go @@ -0,0 +1,302 @@ +package gcal_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" +) + +// Calendars the fake lists: two the household can push to, two it cannot, +// and a third writable one with Google fields MealQuest must not pass on. +var ( + ownPrimary = gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"} + family = gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", + Description: "private notes", SummaryOverride: "Fam"} + holidays = gcaltest.Calendar{ID: "en.usa#holiday@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"} + busy = gcaltest.Calendar{ID: "coworker@example.com", Summary: "Coworker", AccessRole: "freeBusyReader"} + dinners = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"} +) + +// connected is a harness with the household's Google account connected +// and the fake listing every calendar above, two to a page. +func connected(t *testing.T) *harness { + t.Helper() + h := newHarness(t) + c := h.connect(t, session) + if _, err := h.callback(c, session, h.consented(c)); err != nil { + t.Fatalf("Complete: %v", err) + } + h.google.SetCalendars(2, ownPrimary, holidays, family, busy, dinners) + return h +} + +func TestCalendarsListsWritableCalendars(t *testing.T) { + t.Parallel() + h := connected(t) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + got, err := json.Marshal(cals) + if err != nil { + t.Fatal(err) + } + // Exactly the writer and owner calendars, in Google's order, with only + // id, summary, primary and accessRole. + want := `[` + + `{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` + + `{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` + + `{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]` + if string(got) != want { + t.Errorf("Calendars =\n%s\nwant\n%s", got, want) + } + if n := h.google.CalendarCalls(); n != 3 { + t.Errorf("calendarList called %d times, want 3 (every page)", n) + } + if _, ok := h.row(t); !ok { + t.Error("listing changed the connection") + } +} + +func TestCalendarsOverlappingPages(t *testing.T) { + t.Parallel() + h := connected(t) + // Two to a page, family ends the first page and starts the second + // again, renamed in between. + renamed := family + renamed.Summary = "Family (renamed)" + h.google.SetCalendars(2, ownPrimary, family, renamed, dinners) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + got, err := json.Marshal(cals) + if err != nil { + t.Fatal(err) + } + // family once, as first listed, in Google's order. + want := `[` + + `{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` + + `{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` + + `{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]` + if string(got) != want { + t.Errorf("Calendars =\n%s\nwant\n%s", got, want) + } + if n := h.google.CalendarCalls(); n != 2 { + t.Errorf("calendarList called %d times, want 2 (every page)", n) + } +} + +func TestCalendarsEmpty(t *testing.T) { + t.Parallel() + h := connected(t) + h.google.SetCalendars(0, holidays) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + if got, _ := json.Marshal(cals); string(got) != "[]" { + t.Errorf("Calendars = %s, want []", got) + } +} + +func TestCalendarsNotConnected(t *testing.T) { + t.Parallel() + h := newHarness(t) + if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrNotConnected) { + t.Errorf("Calendars = %v, want ErrNotConnected", err) + } + if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrNotConnected) { + t.Errorf("SelectCalendar = %v, want ErrNotConnected", err) + } + if h.google.TokenCalls() != 0 || h.google.CalendarCalls() != 0 { + t.Error("Google was called with nothing connected") + } +} + +func TestSelectCalendarStoresIDAndName(t *testing.T) { + t.Parallel() + h := connected(t) + before, _ := h.row(t) + h.clock = h.clock.Add(time.Hour) + + sel, err := h.svc.SelectCalendar(context.Background(), family.ID) + if err != nil { + t.Fatalf("SelectCalendar: %v", err) + } + if sel != (gcal.Selection{CalendarID: family.ID, CalendarName: "Family"}) { + t.Errorf("selection = %+v", sel) + } + row, _ := h.row(t) + if row.CalendarID.String != family.ID || !row.CalendarID.Valid || + row.CalendarName.String != "Family" || !row.CalendarName.Valid { + t.Errorf("stored calendar = %v %v, want %s Family", row.CalendarID, row.CalendarName, family.ID) + } + if row.UpdatedAt != "2026-10-07T19:30:00.000Z" { + t.Errorf("updated_at = %q, want the choice's time", row.UpdatedAt) + } + if !bytes.Equal(row.RefreshTokenCt, before.RefreshTokenCt) || row.ConnectedAt != before.ConnectedAt || row.CreatedAt != before.CreatedAt { + t.Error("choosing a calendar changed the connection") + } + + // Choosing again replaces the choice. + if _, err := h.svc.SelectCalendar(context.Background(), ownPrimary.ID); err != nil { + t.Fatalf("second SelectCalendar: %v", err) + } + if row, _ := h.row(t); row.CalendarID.String != ownPrimary.ID || row.CalendarName.String != ownPrimary.Summary { + t.Errorf("stored calendar = %v %v, want the primary", row.CalendarID, row.CalendarName) + } +} + +func TestSelectCalendarRejectsUnlistedCalendar(t *testing.T) { + t.Parallel() + for name, id := range map[string]string{ + "unknown": "nobody@group.calendar.google.com", + "reader": holidays.ID, + "free/busy reader": busy.ID, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil { + t.Fatal(err) + } + before, _ := h.row(t) + if _, err := h.svc.SelectCalendar(context.Background(), id); !errors.Is(err, gcal.ErrUnknownCalendar) { + t.Fatalf("SelectCalendar(%s) = %v, want ErrUnknownCalendar", id, err) + } + if row, _ := h.row(t); !reflect.DeepEqual(row, before) { + t.Errorf("a refused choice changed the row: %+v", row) + } + }) + } +} + +// TestSelectCalendarNoLongerWritable chooses a calendar, then has Google +// stop listing it as writable, as when it is deleted or the household's +// access drops to reader. It leaves the listing, and choosing it again is +// refused; the stored choice stays until the household chooses another. +func TestSelectCalendarNoLongerWritable(t *testing.T) { + t.Parallel() + for name, now := range map[string][]gcaltest.Calendar{ + "deleted": {ownPrimary}, + "demoted to read": {ownPrimary, {ID: family.ID, Summary: family.Summary, AccessRole: "reader"}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + if _, err := h.svc.SelectCalendar(context.Background(), family.ID); err != nil { + t.Fatal(err) + } + h.google.SetCalendars(0, now...) + cals, err := h.svc.Calendars(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(cals) != 1 || cals[0].ID != ownPrimary.ID { + t.Errorf("Calendars = %+v, want only the primary", cals) + } + if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrUnknownCalendar) { + t.Errorf("choosing it again = %v, want ErrUnknownCalendar", err) + } + if row, _ := h.row(t); row.CalendarID.String != family.ID { + t.Errorf("stored calendar = %v, want the earlier choice kept", row.CalendarID) + } + }) + } +} + +// TestCalendarsGrantNoLongerAccepted lists calendars after Google stops +// accepting MealQuest's grant. The failure is ErrReconnect; the connection +// is left as it is. +func TestCalendarsGrantNoLongerAccepted(t *testing.T) { + t.Parallel() + for name, edit := range map[string]func(*gcaltest.Google){ + "refresh refused": (*gcaltest.Google).RevokeGrant, + "calendar list unauthorized": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusUnauthorized) }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + edit(h.google) + _, err := h.svc.Calendars(context.Background()) + if !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Calendars = %v, want ErrReconnect", err) + } + assertNoSecret(t, "error", err.Error()) + _, err = h.svc.SelectCalendar(context.Background(), family.ID) + if !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("SelectCalendar = %v, want ErrReconnect", err) + } + row, ok := h.row(t) + if !ok || row.CalendarID.Valid { + t.Errorf("row = %+v, %v: want the connection kept with no calendar", row, ok) + } + }) + } +} + +func TestCalendarsGoogleFailureIsRedacted(t *testing.T) { + t.Parallel() + for name, edit := range map[string]func(*gcaltest.Google){ + "token endpoint": func(g *gcaltest.Google) { g.FailToken(http.StatusServiceUnavailable) }, + "calendar list": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusForbidden) }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + h := connected(t) + edit(h.google) + _, err := h.svc.Calendars(context.Background()) + var calendar *gcal.CalendarError + if !errors.As(err, &calendar) { + t.Fatalf("Calendars = %v, want *CalendarError", err) + } + assertNoSecret(t, "error", err.Error()) + }) + } +} + +// TestSelectCalendarHoldsTheAccount holds a calendar choice at Google while +// a disconnect starts: the disconnect waits for the choice, so it never +// revokes the token the choice is using. +func TestSelectCalendarHoldsTheAccount(t *testing.T) { + t.Parallel() + h := connected(t) + h.google.SetCalendars(0, family) + arrived, release := h.google.HoldCalendarList() + defer release() + selected := make(chan error, 1) + go func() { + _, err := h.svc.SelectCalendar(context.Background(), family.ID) + selected <- err + }() + <-arrived + disconnected := make(chan error, 1) + go func() { disconnected <- h.svc.Disconnect(context.Background()) }() + select { + case err := <-disconnected: + t.Fatalf("Disconnect finished (%v) while a calendar choice was at Google", err) + case <-time.After(200 * time.Millisecond): + } + if n := len(h.google.Revoked()); n != 0 { + t.Fatalf("revoked %d tokens while a calendar choice was at Google", n) + } + release() + if err := <-selected; err != nil { + t.Errorf("SelectCalendar: %v", err) + } + if err := <-disconnected; err != nil { + t.Errorf("Disconnect: %v", err) + } + if _, ok := h.row(t); ok { + t.Error("connection stored after the disconnect") + } +} diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go new file mode 100644 index 0000000..28aadc7 --- /dev/null +++ b/internal/gcal/connect.go @@ -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)} +} diff --git a/internal/gcal/connect_test.go b/internal/gcal/connect_test.go new file mode 100644 index 0000000..9072597 --- /dev/null +++ b/internal/gcal/connect_test.go @@ -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() +} diff --git a/internal/gcal/events.go b/internal/gcal/events.go new file mode 100644 index 0000000..494805f --- /dev/null +++ b/internal/gcal/events.go @@ -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 +} diff --git a/internal/gcal/events_test.go b/internal/gcal/events_test.go new file mode 100644 index 0000000..471acba --- /dev/null +++ b/internal/gcal/events_test.go @@ -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) + } +} diff --git a/internal/gcal/export_test.go b/internal/gcal/export_test.go new file mode 100644 index 0000000..71b67a1 --- /dev/null +++ b/internal/gcal/export_test.go @@ -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 } diff --git a/internal/gcal/gcaltest/gcaltest.go b/internal/gcal/gcaltest/gcaltest.go new file mode 100644 index 0000000..78d88af --- /dev/null +++ b/internal/gcal/gcaltest/gcaltest.go @@ -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[:]) +} diff --git a/internal/gcal/grant.go b/internal/gcal/grant.go new file mode 100644 index 0000000..d704206 --- /dev/null +++ b/internal/gcal/grant.go @@ -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)} + } + } +} diff --git a/internal/gcal/grant_test.go b/internal/gcal/grant_test.go new file mode 100644 index 0000000..3993ee7 --- /dev/null +++ b/internal/gcal/grant_test.go @@ -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) +} diff --git a/internal/plan/draft.go b/internal/plan/draft.go index 5ef6cbd..b1cc157 100644 --- a/internal/plan/draft.go +++ b/internal/plan/draft.go @@ -136,9 +136,10 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill // storeDraft stores week as the plan of the week starting weekStart in one // transaction and returns the plan's id. A new plan is draft. A draft -// replaces the days of an existing plan, keeping each day's calendar -// event id; a draft plan stays draft and a synced or changed one becomes -// changed, since Calendar no longer shows it. A plan synced since the +// replaces the days of an existing plan, keeping the calendar event id of +// each day whose dinner stays the same and clearing the others'; a draft +// plan stays draft and a synced or changed one becomes changed, since +// Calendar no longer shows it. A plan synced since the // draft was asked for without confirm is left as it is, failing with // ReasonConfirmRequired, and so is a plan no longer at revision base (the // revision before draft_week ran, or noPlan), failing with diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 15291d5..c016212 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -4,7 +4,7 @@ // a day from Monday to Sunday, and the job stores them as the week's plan, // replacing any draft before it unless the household edited the plan // while draft_week ran. A plan is draft until Push to Calendar -// syncs it (Feature 16); an edit of a synced plan makes it changed. +// syncs it (Push); an edit of a synced plan makes it changed. // // The Rotation check follows a plan day: MarkTried moves the Quest log // dinners planned on it to tried, and the household answers "Keep it in @@ -17,6 +17,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "slices" "time" @@ -132,19 +133,24 @@ func ParseWeekStart(s string) (store.LocalDate, error) { // skills.DraftWeek with the household's harness.Config. type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) -// Service drafts, reads and edits weekly plans. +// Service drafts, reads, edits and pushes weekly plans. type Service struct { st *store.Store draft Drafter + cal Calendar logger *slog.Logger draftJob *jobs.Kind[DraftJob] + // publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links + // to its dinner. + publicURL *url.URL } -// NewService returns a service over st that drafts with draft and -// registers the KindDraftWeek job on runner, which must not have started -// yet. The kind is service-only: StartDraft, which keeps one draft in -// flight per week, is the only way to enqueue it. -func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// NewService returns a service over st that drafts with draft, pushes to +// cal with links under publicURL, and registers the KindDraftWeek job on +// runner, which must not have started yet. The kind is service-only: +// StartDraft, which keeps one draft in flight per week, is the only way to +// enqueue it. +func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("plan: nil store") @@ -152,10 +158,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slo return nil, errors.New("plan: nil drafter") case runner == nil: return nil, errors.New("plan: nil job runner") + case cal == nil: + return nil, errors.New("plan: nil calendar") + case publicURL == nil: + return nil, errors.New("plan: nil public URL") case logger == nil: return nil, errors.New("plan: nil logger") } - s := &Service{st: st, draft: draft, logger: logger} + s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL} kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) if err != nil { return nil, err @@ -344,8 +354,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con } // Swap plans dinnerID on date of the week starting weekStart, marking the -// day new when the dinner is in the Quest log. Swapping a synced plan -// makes it changed. The week must have a plan (else ErrNotFound), date +// day new when the dinner is in the Quest log and clearing its calendar +// event id, so the next Push writes it. Swapping a synced plan makes it +// changed. The week must have a plan (else ErrNotFound), date // must be one of its days (else *InvalidDayError), the dinner must be a // Regular or in the Quest log (else ErrDinnerNotFound or // *DinnerNotPlannableError), and it must not be on another day of the diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 8795f70..dd48ada 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -2,12 +2,15 @@ package plan_test import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" + "net/url" "slices" "strings" "sync" @@ -18,9 +21,12 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "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/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "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" ) @@ -31,6 +37,9 @@ var ( weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} ) +// publicURL is MEALQUEST_PUBLIC_URL in these tests. +var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} + func mustDate(s string) store.LocalDate { d, err := store.ParseLocalDate(s) if err != nil { @@ -44,6 +53,9 @@ type env struct { st *store.Store runner *jobs.Runner model *llm.Fake + // google is the fake Google the service pushes to through gcal. + google *gcaltest.Google + gcal *gcal.Service // ids are the seeded dinners' ids by name. ids map[string]string @@ -55,7 +67,8 @@ type env struct { // America/New_York with dinner time 17:30, the Regulars below and Quest // log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner // with two workers. It drafts through the real harness and draft_week with -// the scripted e.model. +// the scripted e.model, and pushes through gcal to e.google, which starts +// unconnected. func newEnv(t *testing.T) *env { t.Helper() ctx := t.Context() @@ -111,7 +124,26 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = plan.NewService(st, drafter, e.runner, logger) + e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + e.gcal, err = gcal.NewService(st, box, gcal.Config{ + ClientID: gcaltest.ClientID, + ClientSecret: gcaltest.ClientSecret, + PublicURL: publicURL, + SecretKey: []byte("server-secret-key"), + Endpoint: e.google.Endpoint(), + RevokeURL: e.google.RevokeURL(), + CalendarAPI: e.google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger) if err != nil { t.Fatal(err) } @@ -781,12 +813,26 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) { if got := dayNames(p); !slices.Equal(got, reversed) { t.Errorf("days = %v, want %v", got, reversed) } - var events int - if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil { + // Reversed, only Thursday keeps its dinner, so only Thursday + // keeps its event id: Calendar no longer shows the others as + // planned. + rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`) + if err != nil { t.Fatal(err) } - if events != 7 { - t.Errorf("%d days kept their calendar event id, want 7", events) + var kept []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err != nil { + t.Fatal(err) + } + kept = append(kept, date) + } + if err := errors.Join(rows.Err(), rows.Close()); err != nil { + t.Fatal(err) + } + if !slices.Equal(kept, []string{"2026-10-15"}) { + t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept) } }) } diff --git a/internal/plan/push.go b/internal/plan/push.go new file mode 100644 index 0000000..e002397 --- /dev/null +++ b/internal/plan/push.go @@ -0,0 +1,223 @@ +package plan + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base32" + "errors" + "fmt" + "strings" + "time" + + "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/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// eventLength is how long a dinner's Calendar event lasts. +const eventLength = 60 * time.Minute + +// ErrPlanChanged is a Push to Calendar of a plan edited (swapped or +// redrafted) while it was being pushed: Calendar may show the plan as it +// was, so the push is not recorded and pushing again brings Calendar up to +// date. +var ErrPlanChanged = errors.New("the plan changed while it was being pushed to Calendar; push again") + +// Calendar writes events to the household's chosen calendar, then runs +// record while that calendar stays chosen. In production it is +// *gcal.Service. +type Calendar interface { + PushEvents(ctx context.Context, events []gcal.Event, record func() error) error +} + +// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet +// for event ids. +var eventIDEncoding = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding) + +// EventID is the Calendar event id of date of the week starting weekStart: +// the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}", 52 +// characters of a-v and 0-9, within Google's rule for event ids (that +// alphabet, 5 to 1024 characters). A day always has the same id, so +// pushing it again updates its event rather than adding another. +func EventID(weekStart, date store.LocalDate) string { + sum := sha256.Sum256([]byte("mealquest|" + weekStart.String() + "|" + date.String())) + return eventIDEncoding.EncodeToString(sum[:]) +} + +// pushDay is a day of a plan being pushed: its date and its event. +type pushDay struct { + date string + event gcal.Event +} + +// Push pushes the plan of the week starting weekStart to the household's +// chosen calendar and returns the plan as synced. Each day is an event +// "Dinner: {name}" from the household's dinner time on that date, in the +// household's zone, for an hour; its description is the dinner's name, +// "Start by {time}" for a crock-pot dinner whose minutes are known, and +// the dinner's link under the public URL. Its id is EventID. +// +// A push writes the days Calendar does not show as planned: those never +// pushed, swapped, or redrafted to another dinner since, whose +// calendar_event_id is empty. When every day has one, Calendar shows the +// whole plan and every day is written again, which puts back anything +// changed in Calendar since: the app is the source of truth. A stored event +// id is always on the chosen calendar: choosing another +// (gcal.Service.SelectCalendar) or disconnecting (gcal.Service.Disconnect) +// clears them all, so the next push writes the whole week. +// +// Only a push that wrote every one of those events is recorded: the plan +// becomes synced with calendar_synced_at now, and each day written stores +// its event id. A push that failed part way records nothing and leaves the +// plan's status as it was; since event ids are fixed, pushing again +// updates the events already written rather than adding them twice. The +// push is recorded while the calendar written to is still the chosen one. +// A plan edited while it was pushed, or whose events were forgotten by +// choosing another calendar or disconnecting, fails with ErrPlanChanged, +// unrecorded. +// +// The week must have a plan (else ErrNotFound). The calendar's errors are +// returned as they are: gcal.ErrNotConnected, gcal.ErrNoCalendar, +// gcal.ErrCalendarUnavailable, gcal.ErrReconnect or *gcal.CalendarError. +// +// A successful Push is the household confirming the week's plan, which is +// what the week's Supply run (Feature 17) is made from; the returned plan +// is the one pushed. +func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) { + if _, err := household.WeekDays(weekStart); err != nil { + return Plan{}, &InvalidWeekError{Value: weekStart.String()} + } + planID, revision, days, err := s.pushDays(ctx, weekStart) + if err != nil { + return Plan{}, err + } + events := make([]gcal.Event, len(days)) + for i, d := range days { + events[i] = d.event + } + var p Plan + record := func() error { + return s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + now := store.Now().String() + n, err := q.MarkWeekPlanSynced(ctx, db.MarkWeekPlanSyncedParams{ + CalendarSyncedAt: sql.NullString{String: now, Valid: true}, + UpdatedAt: now, + ID: planID, + Revision: revision, + }) + if err != nil { + return fmt.Errorf("plan: mark %s synced: %w", planID, err) + } + if n != 1 { + return ErrPlanChanged + } + for _, d := range days { + n, err := q.SetPlanDayCalendarEvent(ctx, db.SetPlanDayCalendarEventParams{ + CalendarEventID: sql.NullString{String: d.event.ID, Valid: true}, + UpdatedAt: now, + PlanID: planID, + Date: d.date, + }) + if err != nil { + return fmt.Errorf("plan: record event of %s of %s: %w", d.date, planID, err) + } + if n != 1 { + return fmt.Errorf("plan: %s has no day %s", planID, d.date) + } + } + p, err = readPlan(ctx, q, weekStart) + return err + }) + } + if err := s.cal.PushEvents(ctx, events, record); err != nil { + return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) + } + return p, nil +} + +// pushDays reads, in one read transaction, the plan of the week starting +// weekStart, its revision, and the days a push writes with their events. +func (s *Service) pushDays(ctx context.Context, weekStart store.LocalDate) (planID string, revision int64, days []pushDay, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + row, err := q.GetWeekPlan(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return "", 0, nil, ErrNotFound + } + if err != nil { + return "", 0, nil, fmt.Errorf("plan: read week %s: %w", weekStart, err) + } + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: household settings: %w", err) + } + rows, err := q.ListPlanDays(ctx, row.ID) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: read days of %s: %w", row.ID, err) + } + dates, err := household.WeekDays(weekStart) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: %w", err) + } + if len(rows) != len(dates) { + return "", 0, nil, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates)) + } + all := make([]pushDay, len(rows)) + for i, r := range rows { + if r.Date != dates[i].String() { + return "", 0, nil, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i]) + } + event, err := s.event(zone, settings.Timezone, settings.DinnerTime, weekStart, dates[i], r.Dinner) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: event of %s: %w", r.Date, err) + } + d := pushDay{date: r.Date, event: event} + all[i] = d + if !r.CalendarEventID.Valid { + days = append(days, d) + } + } + if len(days) == 0 { + days = all + } + return row.ID, row.Revision, days, nil +} + +// event is the Calendar event of dinner d on date of the week starting +// weekStart, at dinnerTime in zone, named zoneName. +func (s *Service) event(zone *time.Location, zoneName, dinnerTime string, weekStart, date store.LocalDate, d db.Dinner) (gcal.Event, error) { + start, err := household.LocalTime(zone, date, dinnerTime) + if err != nil { + return gcal.Event{}, err + } + lines := []string{d.Name} + startBy, err := startBy(zone, date, dinnerTime, d) + if err != nil { + return gcal.Event{}, err + } + if startBy != nil { + lines = append(lines, "Start by "+startBy.In(zone).Format("3:04 pm")) + } + lines = append(lines, s.publicURL.JoinPath("dinner", d.ID).String()) + return gcal.Event{ + ID: EventID(weekStart, date), + Summary: "Dinner: " + d.Name, + Description: strings.Join(lines, "\n"), + Start: start, + End: start.Add(eventLength), + TimeZone: zoneName, + }, nil +} diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go new file mode 100644 index 0000000..ac570a4 --- /dev/null +++ b/internal/plan/push_test.go @@ -0,0 +1,840 @@ +package plan_test + +import ( + "crypto/sha256" + "database/sql" + "encoding/base32" + "errors" + "maps" + "net/http" + "regexp" + "slices" + "strings" + "testing" + "time" + + "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/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// dinnersCal is the calendar the tests push to. +var dinnersCal = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"} + +// connectCalendar connects the household's Google account and chooses +// dinnersCal. +func (e *env) connectCalendar(t *testing.T) { + t.Helper() + e.google.SetCalendars(0, dinnersCal) + e.google.Connect(t, e.gcal) + if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil { + t.Fatal(err) + } +} + +// storePlan stores a draft plan of the week starting weekStart with the +// dinners named names, Monday first, and returns its days' dates. +func (e *env) storePlan(t *testing.T, weekStart store.LocalDate, names ...string) []string { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + id := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: weekStart.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + y, m, d := weekStart.Date() + var dates []string + for i, name := range names { + date := store.LocalDateOf(time.Date(y, m, d+i, 0, 0, 0, 0, time.UTC)).String() + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date, + DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + dates = append(dates, date) + } + return dates +} + +// eventIDs returns each day's stored calendar_event_id of the week +// starting weekStart, "" for none, by date. +func (e *env) eventIDs(t *testing.T, weekStart store.LocalDate) map[string]string { + t.Helper() + rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT plan_days.date, plan_days.calendar_event_id FROM plan_days + JOIN week_plans ON week_plans.id = plan_days.plan_id WHERE week_plans.week_start = ?`, weekStart.String()) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var date string + var id sql.NullString + if err := rows.Scan(&date, &id); err != nil { + t.Fatal(err) + } + out[date] = id.String + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// assertUnsynced fails unless the plan of weekStart has status, no +// calendar_synced_at and no stored event ids. +func (e *env) assertUnsynced(t *testing.T, weekStart store.LocalDate, status string) { + t.Helper() + p, err := e.svc.Get(t.Context(), weekStart) + if err != nil { + t.Fatal(err) + } + if p.Status != status || p.CalendarSyncedAt != nil { + t.Errorf("plan = %s synced at %v, want %s and never synced", p.Status, p.CalendarSyncedAt, status) + } + for date, id := range e.eventIDs(t, weekStart) { + if id != "" { + t.Errorf("%s stored event id %q after a failed push", date, id) + } + } +} + +// 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 TestEventIDIsDeterministicAndWithinGoogleRules(t *testing.T) { + monday, tuesday := mustDate("2026-10-12"), mustDate("2026-10-13") + id := plan.EventID(monday, tuesday) + sum := sha256.Sum256([]byte("mealquest|2026-10-12|2026-10-13")) + if want := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])); id != want { + t.Errorf("EventID = %s, want %s", id, want) + } + // Google: base32hex characters (a-v, 0-9), 5 to 1024 of them. + if !regexp.MustCompile(`^[a-v0-9]{5,1000}$`).MatchString(id) || len(id) != 52 { + t.Errorf("EventID = %q (%d characters), want 52 of a-v and 0-9", id, len(id)) + } + if again := plan.EventID(monday, tuesday); again != id { + t.Errorf("EventID changed between calls: %s, %s", id, again) + } + if plan.EventID(monday, monday) == id || plan.EventID(mustDate("2026-10-19"), tuesday) == id { + t.Error("EventID is the same for another date or week") + } +} + +func TestPushCreatesOneEventPerDay(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := e.storePlan(t, week, fullWeek...) + + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("Push: %v", err) + } + if p.Status != plan.StatusSynced || p.CalendarSyncedAt == nil { + t.Errorf("pushed plan = %s synced at %v, want synced with a time", p.Status, p.CalendarSyncedAt) + } + if inserts, updates := e.google.EventWrites(); inserts != 7 || updates != 0 { + t.Errorf("Google saw %d inserts and %d updates, want 7 and 0", inserts, updates) + } + events := e.google.Events(dinnersCal.ID) + if len(events) != 7 { + t.Fatalf("calendar has %d events, want 7", len(events)) + } + stored := e.eventIDs(t, week) + for i, date := range dates { + id := plan.EventID(week, mustDate(date)) + ev, ok := events[id] + if !ok { + t.Errorf("%s: no event %s", date, id) + continue + } + if stored[date] != id { + t.Errorf("%s stored event id %q, want %s", date, stored[date], id) + } + name := fullWeek[i] + if ev.Summary != "Dinner: "+name || ev.Status != "confirmed" { + t.Errorf("%s event = %q %s, want \"Dinner: %s\" confirmed", date, ev.Summary, ev.Status, name) + } + // Dinner time 17:30 in New York, EDT all week, for an hour. + if want := (gcaltest.EventTime{DateTime: date + "T17:30:00-04:00", TimeZone: "America/New_York"}); ev.Start != want { + t.Errorf("%s start = %+v, want %+v", date, ev.Start, want) + } + if want := (gcaltest.EventTime{DateTime: date + "T18:30:00-04:00", TimeZone: "America/New_York"}); ev.End != want { + t.Errorf("%s end = %+v, want %+v", date, ev.End, want) + } + } + // Beef Chili cooks 240 minutes in the crock pot; Pot Roast's minutes + // are unknown, so it has no start time, and Pad Thai is not a crock-pot + // dinner. + for _, tc := range []struct{ date, want string }{ + {"2026-10-12", "Beef Chili\nStart by 1:30 pm\nhttps://meals.example.com/dinner/" + e.ids["Beef Chili"]}, + {"2026-10-13", "Pot Roast\nhttps://meals.example.com/dinner/" + e.ids["Pot Roast"]}, + {"2026-10-14", "Pad Thai\nhttps://meals.example.com/dinner/" + e.ids["Pad Thai"]}, + } { + if got := events[plan.EventID(week, mustDate(tc.date))].Description; got != tc.want { + t.Errorf("%s description = %q, want %q", tc.date, got, tc.want) + } + } + got, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if got.Status != plan.StatusSynced || got.CalendarSyncedAt == nil || *got.CalendarSyncedAt != *p.CalendarSyncedAt { + t.Errorf("stored plan = %s synced at %v, want as pushed", got.Status, got.CalendarSyncedAt) + } +} + +func TestPushAgainUpdatesOnly(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); err != nil { + t.Fatal(err) + } + // The household deletes Monday's event in Google Calendar; Google keeps + // its id. + monday := plan.EventID(week, week) + e.google.CancelEvent(dinnersCal.ID, monday) + e.google.ResetEventWrites() + + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("second Push: %v", err) + } + if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 { + t.Errorf("second push: %d inserts and %d updates, want 0 and 7", inserts, updates) + } + events := e.google.Events(dinnersCal.ID) + if len(events) != 7 { + t.Errorf("calendar has %d events, want 7", len(events)) + } + if events[monday].Status != "confirmed" { + t.Errorf("Monday's event is %s, want it put back", events[monday].Status) + } + if p.Status != plan.StatusSynced { + t.Errorf("status = %s, want synced", p.Status) + } +} + +func TestSwapThenPushUpdatesThatDay(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); err != nil { + t.Fatal(err) + } + before := e.google.Events(dinnersCal.ID) + e.google.ResetEventWrites() + + fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + swapped, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos) + if err != nil { + t.Fatal(err) + } + if swapped.Status != plan.StatusChanged { + t.Fatalf("status after swap = %s, want changed", swapped.Status) + } + if id := e.eventIDs(t, week)["2026-10-14"]; id != "" { + t.Errorf("swapped day kept event id %q", id) + } + + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("Push: %v", err) + } + if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 1 { + t.Errorf("push after swap: %d inserts and %d updates, want 0 and 1", inserts, updates) + } + wednesday := plan.EventID(week, mustDate("2026-10-14")) + after := e.google.Events(dinnersCal.ID) + if got := after[wednesday]; got.Summary != "Dinner: Fish Tacos" || + got.Description != "Fish Tacos\nhttps://meals.example.com/dinner/"+fishTacos { + t.Errorf("Wednesday's event = %+v, want Fish Tacos", got) + } + delete(before, wednesday) + delete(after, wednesday) + if !maps.Equal(before, after) { + t.Errorf("events of other days changed:\nbefore %+v\nafter %+v", before, after) + } + if p.Status != plan.StatusSynced { + t.Errorf("status = %s, want synced", p.Status) + } + if id := e.eventIDs(t, week)["2026-10-14"]; id != wednesday { + t.Errorf("Wednesday stored event id %q, want %s", id, wednesday) + } +} + +func TestPushAcrossDaylightSavingTime(t *testing.T) { + for _, tc := range []struct { + name string + week store.LocalDate + // utc is each day's 17:30 New York dinner as a UTC instant. + utc []string + }{ + // New York leaves daylight saving time at 02:00 on Sunday + // 2026-11-01. + {"fall back", mustDate("2026-10-26"), []string{ + "2026-10-26T21:30:00Z", "2026-10-27T21:30:00Z", "2026-10-28T21:30:00Z", "2026-10-29T21:30:00Z", + "2026-10-30T21:30:00Z", "2026-10-31T21:30:00Z", "2026-11-01T22:30:00Z", + }}, + // It enters it at 02:00 on Sunday 2027-03-14. + {"spring forward", mustDate("2027-03-08"), []string{ + "2027-03-08T22:30:00Z", "2027-03-09T22:30:00Z", "2027-03-10T22:30:00Z", "2027-03-11T22:30:00Z", + "2027-03-12T22:30:00Z", "2027-03-13T22:30:00Z", "2027-03-14T21:30:00Z", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := e.storePlan(t, tc.week, fullWeek...) + if _, err := e.svc.Push(t.Context(), tc.week); err != nil { + t.Fatalf("Push: %v", err) + } + events := e.google.Events(dinnersCal.ID) + for i, date := range dates { + ev := events[plan.EventID(tc.week, mustDate(date))] + start, err := time.Parse(time.RFC3339, ev.Start.DateTime) + if err != nil { + t.Fatalf("%s start %q: %v", date, ev.Start.DateTime, err) + } + end, err := time.Parse(time.RFC3339, ev.End.DateTime) + if err != nil { + t.Fatalf("%s end %q: %v", date, ev.End.DateTime, err) + } + if got := start.UTC().Format(time.RFC3339); got != tc.utc[i] { + t.Errorf("%s starts %s (%s), want %s", date, got, ev.Start.DateTime, tc.utc[i]) + } + if !strings.HasPrefix(ev.Start.DateTime, date+"T17:30:00") { + t.Errorf("%s starts %s, want 17:30 local", date, ev.Start.DateTime) + } + if end.Sub(start) != time.Hour { + t.Errorf("%s lasts %v, want 1h", date, end.Sub(start)) + } + if ev.Start.TimeZone != "America/New_York" || ev.End.TimeZone != "America/New_York" { + t.Errorf("%s zones = %s, %s", date, ev.Start.TimeZone, ev.End.TimeZone) + } + } + }) + } +} + +func TestPushNeedsAChosenCalendar(t *testing.T) { + e := newEnv(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) { + t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err) + } + e.google.SetCalendars(0, dinnersCal) + e.google.Connect(t, e.gcal) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { + t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err) + } + if inserts, updates := e.google.EventWrites(); inserts+updates != 0 { + t.Errorf("Google saw %d event writes", inserts+updates) + } + e.assertUnsynced(t, week, plan.StatusDraft) +} + +func TestPushWithoutAPlan(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + before := e.google.TokenCalls() + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + t.Errorf("Push = %v, want ErrNotFound", err) + } + if n := e.google.TokenCalls() - before; n != 0 { + t.Errorf("token endpoint called %d times for a week with no plan", n) + } +} + +func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + // Google fails the fourth event on every try. + e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable) + + _, err := e.svc.Push(t.Context(), week) + var calendar *gcal.CalendarError + if !errors.As(err, &calendar) || !calendar.Retryable() { + t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err) + } + assertNoSecret(t, "error", err.Error()) + if n := len(e.google.Events(dinnersCal.ID)); n != 3 { + t.Fatalf("calendar has %d events, want the 3 before the failure", n) + } + e.assertUnsynced(t, week, plan.StatusDraft) + + // Pushing again writes every day: the three already there are updated, + // not added twice. + e.google.ResetEventWrites() + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("retried Push: %v", err) + } + if inserts, updates := e.google.EventWrites(); inserts != 4 || updates != 3 { + t.Errorf("retry: %d inserts and %d updates, want 4 and 3", inserts, updates) + } + if n := len(e.google.Events(dinnersCal.ID)); n != 7 || p.Status != plan.StatusSynced { + t.Errorf("after retry: %d events, plan %s; want 7, synced", n, p.Status) + } +} + +func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + first, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatal(err) + } + for date, name := range map[string]string{"2026-10-12": "Fish Tacos", "2026-10-13": "Ramen"} { + if _, err := e.svc.Swap(t.Context(), week, date, e.dinner(t, name, "regular", "stovetop", 25)); err != nil { + t.Fatal(err) + } + } + e.google.FailEventWrite(2, http.StatusBadRequest) + if _, err := e.svc.Push(t.Context(), week); err == nil { + t.Fatal("Push succeeded, want the second event to fail") + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt { + t.Errorf("plan = %s synced at %v, want changed, synced at %s", p.Status, *p.CalendarSyncedAt, *first.CalendarSyncedAt) + } + ids := e.eventIDs(t, week) + if ids["2026-10-12"] != "" || ids["2026-10-13"] != "" { + t.Errorf("swapped days stored event ids %v after a failed push", ids) + } +} + +func TestPushToStaleCalendar(t *testing.T) { + for name, edit := range map[string]func(*gcaltest.Google){ + "deleted": func(g *gcaltest.Google) { g.SetCalendars(0) }, + "read-only": func(g *gcaltest.Google) { + g.SetCalendars(0, gcaltest.Calendar{ID: dinnersCal.ID, Summary: "Dinners", AccessRole: "reader"}) + }, + } { + t.Run(name, func(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + edit(e.google) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) { + t.Errorf("Push = %v, want ErrCalendarUnavailable", err) + } + e.assertUnsynced(t, week, plan.StatusDraft) + }) + } +} + +func TestPushGrantNoLongerAccepted(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + e.google.RevokeGrant() + _, err := e.svc.Push(t.Context(), week) + if !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Push = %v, want ErrReconnect", err) + } + assertNoSecret(t, "error", err.Error()) + e.assertUnsynced(t, week, plan.StatusDraft) + e.assertRefused(t) +} + +// assertRefused fails unless the connection is recorded as reconnect +// required: its refresh token cleared, last_sync_error the reason, and the +// chosen calendar kept. +func (e *env) assertRefused(t *testing.T) { + t.Helper() + row, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()) + if err != nil { + t.Fatalf("connection after a refused grant: %v", err) + } + if row.RefreshTokenCt != nil || row.LastSyncError.String != gcal.ReconnectReason || row.CalendarID.String != dinnersCal.ID { + t.Errorf("connection after a refused grant: token %x, last_sync_error %v, calendar %v; want no token, %q, %s", + row.RefreshTokenCt, row.LastSyncError, row.CalendarID, gcal.ReconnectReason, dinnersCal.ID) + } +} + +// TestRefusedGrantKeepsPushedWeeks: a grant Google refuses clears the +// refresh token but keeps the chosen calendar, where every pushed event +// still is, so every pushed week stays synced and keeps its event ids. +// Connecting again keeps the calendar, and the next push updates the +// events in place. +func TestRefusedGrantKeepsPushedWeeks(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := map[store.LocalDate][]string{} + for _, w := range pushedWeeks { + dates[w] = e.storePlan(t, w, fullWeek...) + if _, err := e.svc.Push(t.Context(), w); err != nil { + t.Fatalf("Push %s: %v", w, err) + } + } + e.google.RevokeGrant() + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Push = %v, want ErrReconnect", err) + } + e.assertRefused(t) + for _, w := range pushedWeeks { + if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced { + t.Errorf("plan of %s after a refused grant = %s, %v; want synced", w, p.Status, err) + } + e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...) + } + + // The refused token is not offered to Google again. + before := e.google.TokenCalls() + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Push again = %v, want ErrReconnect", err) + } + if n := e.google.TokenCalls() - before; n != 0 { + t.Errorf("token endpoint called %d times after the grant was refused", n) + } + + e.google.Connect(t, e.gcal) + e.google.ResetEventWrites() + p, err := e.svc.Push(t.Context(), week) + if err != nil || p.Status != plan.StatusSynced { + t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err) + } + if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 { + t.Errorf("push after connecting again: %d inserts and %d updates, want 0 and 7", inserts, updates) + } + e.assertWeekOn(t, dinnersCal.ID, week, dates[week], fullWeek...) +} + +// TestDisconnectAfterRefusedGrant: a connection whose grant Google refused +// has no token to revoke; disconnecting deletes it and forgets every pushed +// event, as any disconnect does. +func TestDisconnectAfterRefusedGrant(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); err != nil { + t.Fatal(err) + } + e.google.RevokeGrant() + if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Calendars = %v, want ErrReconnect", err) + } + e.assertRefused(t) + if err := e.gcal.Disconnect(t.Context()); err != nil { + t.Fatalf("Disconnect: %v", err) + } + if n := len(e.google.Revoked()); n != 0 { + t.Errorf("revoked %d tokens; a refused grant has none to revoke", n) + } + if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("connection after disconnecting = %v, want none", err) + } + e.assertForgotten(t, week, "after disconnecting") +} + +func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + arrived, release := e.google.HoldEvents() + defer release() + pushed := make(chan error, 1) + go func() { + _, err := e.svc.Push(t.Context(), week) + pushed <- err + }() + <-arrived + if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)); err != nil { + t.Fatal(err) + } + release() + if err := <-pushed; !errors.Is(err, plan.ErrPlanChanged) { + t.Fatalf("Push = %v, want ErrPlanChanged", err) + } + e.assertUnsynced(t, week, plan.StatusDraft) +} + +// kitchenCal is the calendar the tests choose after pushing to dinnersCal. +var kitchenCal = gcaltest.Calendar{ID: "kitchen@group.calendar.google.com", Summary: "Kitchen", AccessRole: "owner"} + +// assertWeekOn fails unless calendar shows each of names, Monday first, on +// its day of the week starting weekStart, and each day stores that event's +// id. +func (e *env) assertWeekOn(t *testing.T, calendar string, weekStart store.LocalDate, dates []string, names ...string) { + t.Helper() + events := e.google.Events(calendar) + stored := e.eventIDs(t, weekStart) + for i, date := range dates { + id := plan.EventID(weekStart, mustDate(date)) + if ev := events[id]; ev.Summary != "Dinner: "+names[i] || ev.Status != "confirmed" { + t.Errorf("%s on %s = %q %s, want \"Dinner: %s\" confirmed", date, calendar, ev.Summary, ev.Status, names[i]) + } + if stored[date] != id { + t.Errorf("%s stored event id %q, want %s", date, stored[date], id) + } + } +} + +func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) { + for _, swap := range []bool{false, true} { + name := "push again" + if swap { + name = "swap then push" + } + t.Run(name, func(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := e.storePlan(t, week, fullWeek...) + first, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatal(err) + } + onDinners := e.google.Events(dinnersCal.ID) + + // Choosing the same calendar again keeps the plan synced. + e.google.SetCalendars(0, dinnersCal, kitchenCal) + if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil { + t.Fatal(err) + } + if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusSynced { + t.Fatalf("plan after choosing the same calendar = %s, %v; want synced", p.Status, err) + } + + if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil { + t.Fatal(err) + } + // The plan's events are on a calendar no longer chosen. + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt { + t.Errorf("plan after choosing another calendar = %s synced at %v, want changed", p.Status, *p.CalendarSyncedAt) + } + for date, id := range e.eventIDs(t, week) { + if id != "" { + t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id) + } + } + + names := slices.Clone(fullWeek) + if swap { + fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos); err != nil { + t.Fatal(err) + } + names[2] = "Fish Tacos" + } + + // A push that fails part way leaves the plan changed. + e.google.ResetEventWrites() + e.google.FailEventWrite(4, http.StatusBadRequest) + if _, err := e.svc.Push(t.Context(), week); err == nil { + t.Fatal("Push succeeded, want the fourth event to fail") + } + if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged { + t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err) + } + + p, err = e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("Push: %v", err) + } + if p.Status != plan.StatusSynced { + t.Errorf("status = %s, want synced", p.Status) + } + if n := len(e.google.Events(kitchenCal.ID)); n != 7 { + t.Errorf("%s has %d events, want 7", kitchenCal.ID, n) + } + e.assertWeekOn(t, kitchenCal.ID, week, dates, names...) + if after := e.google.Events(dinnersCal.ID); !maps.Equal(onDinners, after) { + t.Errorf("%s changed:\nbefore %+v\nafter %+v", dinnersCal.ID, onDinners, after) + } + }) + } +} + +// TestChoosingAnotherCalendarWaitsForAPush chooses another calendar while a +// push is at Google: the choice waits until the push is recorded, then +// forgets its events, so the next push writes the whole week to the +// calendar now chosen. +func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := e.storePlan(t, week, fullWeek...) + e.google.SetCalendars(0, dinnersCal, kitchenCal) + arrived, release := e.google.HoldEvents() + defer release() + pushed := make(chan error, 1) + go func() { + _, err := e.svc.Push(t.Context(), week) + pushed <- err + }() + <-arrived + selected := make(chan error, 1) + go func() { + _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID) + selected <- err + }() + select { + case err := <-selected: + t.Fatalf("SelectCalendar finished (%v) while a push was at Google", err) + case <-time.After(200 * time.Millisecond): + } + release() + if err := <-pushed; err != nil { + t.Fatalf("Push: %v", err) + } + if err := <-selected; err != nil { + t.Fatalf("SelectCalendar: %v", err) + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusChanged { + t.Errorf("plan = %s, want changed: its events are on %s", p.Status, dinnersCal.ID) + } + for date, id := range e.eventIDs(t, week) { + if id != "" { + t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id) + } + } + + if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced { + t.Fatalf("Push = %s, %v; want synced", p.Status, err) + } + e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...) +} + +// pushedWeeks are the weeks the disconnect tests push: the calendar and the +// connection are the household's, so a disconnect concerns every one. +var pushedWeeks = []store.LocalDate{week, mustDate("2026-10-19"), mustDate("2026-10-26")} + +// assertForgotten fails unless the plan of weekStart is changed and no day +// keeps an event id. +func (e *env) assertForgotten(t *testing.T, weekStart store.LocalDate, why string) { + t.Helper() + p, err := e.svc.Get(t.Context(), weekStart) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusChanged { + t.Errorf("plan of %s %s = %s, want changed", weekStart, why, p.Status) + } + for date, id := range e.eventIDs(t, weekStart) { + if id != "" { + t.Errorf("%s kept event id %q %s", date, id, why) + } + } +} + +func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := map[store.LocalDate][]string{} + for _, w := range pushedWeeks { + dates[w] = e.storePlan(t, w, fullWeek...) + if _, err := e.svc.Push(t.Context(), w); err != nil { + t.Fatalf("Push %s: %v", w, err) + } + } + draftWeek := mustDate("2026-11-02") + e.storePlan(t, draftWeek, fullWeek...) + + if err := e.gcal.Disconnect(t.Context()); err != nil { + t.Fatalf("Disconnect: %v", err) + } + for _, w := range pushedWeeks { + e.assertForgotten(t, w, "after disconnecting") + } + e.assertUnsynced(t, draftWeek, plan.StatusDraft) + + // Connecting again chooses no calendar, so nothing is pushed until one + // is chosen; then a push writes the whole week to it. + e.google.Connect(t, e.gcal) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { + t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err) + } + e.google.SetCalendars(0, dinnersCal, kitchenCal) + if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil { + t.Fatal(err) + } + for _, w := range pushedWeeks { + e.assertForgotten(t, w, "after connecting again") + p, err := e.svc.Push(t.Context(), w) + if err != nil || p.Status != plan.StatusSynced { + t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err) + } + e.assertWeekOn(t, kitchenCal.ID, w, dates[w], fullWeek...) + } +} + +// TestDisconnectRefusedKeepsPushedWeeks: a revoke Google refuses keeps the +// connection and its calendar, so every pushed week stays synced to it. +func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := map[store.LocalDate][]string{} + for _, w := range pushedWeeks { + dates[w] = e.storePlan(t, w, fullWeek...) + if _, err := e.svc.Push(t.Context(), w); err != nil { + t.Fatalf("Push %s: %v", w, err) + } + } + e.google.AnswerRevoke(http.StatusServiceUnavailable, "") + var revokeErr *gcal.RevokeError + if err := e.gcal.Disconnect(t.Context()); !errors.As(err, &revokeErr) { + t.Fatalf("Disconnect = %v, want a *RevokeError", err) + } + for _, w := range pushedWeeks { + if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced { + t.Errorf("plan of %s after a refused disconnect = %s, %v; want synced", w, p.Status, err) + } + e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...) + } +} + +// TestDisconnectWaitsForAPush disconnects while a push is at Google: the +// disconnect waits until the push is recorded, then forgets its events. +func TestDisconnectWaitsForAPush(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + arrived, release := e.google.HoldEvents() + defer release() + pushed := make(chan error, 1) + go func() { + _, err := e.svc.Push(t.Context(), week) + pushed <- err + }() + <-arrived + disconnected := make(chan error, 1) + go func() { + disconnected <- e.gcal.Disconnect(t.Context()) + }() + select { + case err := <-disconnected: + t.Fatalf("Disconnect finished (%v) while a push was at Google", err) + case <-time.After(200 * time.Millisecond): + } + release() + if err := <-pushed; err != nil { + t.Fatalf("Push: %v", err) + } + if err := <-disconnected; err != nil { + t.Fatalf("Disconnect: %v", err) + } + e.assertForgotten(t, week, "after disconnecting") +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index 89832dd..cd11105 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index fb978c8..1f7aef9 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google.go b/internal/server/google.go new file mode 100644 index 0000000..9e2d085 --- /dev/null +++ b/internal/server/google.go @@ -0,0 +1,203 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" +) + +// googleConnectedPath is where a completed connect sends the browser: the +// Google Calendar settings screen. +const googleConnectedPath = "/settings/calendar" + +// getGoogleConnect sends the browser to Google's consent screen with a state +// bound to its session. The household opens it as a top-level navigation; +// the session cookie is SameSite=Lax, so it rides along. +func getGoogleConnect(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + session, ok := auth.SessionFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), "Google connect without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + consent, err := svc.Connect(w, session) + if err != nil { + logger.ErrorContext(r.Context(), "start Google connect", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + http.Redirect(w, r, consent, http.StatusFound) + } +} + +// getGoogleCallback completes the connect Google redirects back with, then +// sends the browser to the Google Calendar settings screen. Every failure is +// an API error; none holds a token or Google's response text. +func getGoogleCallback(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + ctx := r.Context() + session, ok := auth.SessionFrom(ctx) + if !ok { + logger.ErrorContext(ctx, "Google callback without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + err := svc.Complete(w, r, session) + switch { + case err == nil: + http.Redirect(w, r, googleConnectedPath, http.StatusSeeOther) + case errors.Is(err, gcal.ErrBadState): + logger.WarnContext(ctx, "Google callback refused", "error", err) + writeError(w, r, logger, http.StatusBadRequest, "invalid_state", + "this Google Calendar connection expired or was not started here; connect again") + case errors.Is(err, gcal.ErrDenied): + logger.WarnContext(ctx, "Google returned an authorization error") + writeError(w, r, logger, http.StatusForbidden, "google_denied", + "Google did not grant access to your calendar; connect again to retry") + case errors.Is(err, gcal.ErrNoCode): + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "missing authorization code") + case errors.Is(err, gcal.ErrNoRefreshToken): + logger.WarnContext(ctx, "Google connect refused", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "reconnect_required", + "Google did not grant lasting access to your calendar; connect Google Calendar again") + case errors.Is(err, gcal.ErrScopes): + logger.WarnContext(ctx, "Google connect refused", "error", err) + writeError(w, r, logger, http.StatusForbidden, "scopes_missing", + "allow MealQuest to see your calendars and manage their events, then connect Google Calendar again") + default: + logger.WarnContext(ctx, "Google connect failed", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "google_failed", + "Google Calendar could not be connected; connect again") + } + } +} + +// deleteGoogle revokes the connected account's access at Google and forgets +// it: 204 on success, 409 not_connected when nothing is connected, 502 when +// Google could not revoke (the connection is kept, so it can be retried). +func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := svc.Disconnect(r.Context()) + var revoke *gcal.RevokeError + switch { + case err == nil: + w.WriteHeader(http.StatusNoContent) + case errors.Is(err, gcal.ErrNotConnected): + writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected") + case errors.As(err, &revoke): + logger.WarnContext(r.Context(), "revoke Google access", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "revoke_failed", + "Google could not revoke MealQuest's access; try again") + default: + logger.ErrorContext(r.Context(), "disconnect Google Calendar", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } + } +} + +// 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, and a Google +// failure as writeGoogleFailure does. +func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var calendar *gcal.CalendarError + switch { + 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), errors.As(err, &calendar): + writeGoogleFailure(w, r, logger, action, err, "Google Calendar could not be reached; try again") + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} + +// writeGoogleFailure answers err, gcal.ErrReconnect or a *gcal.CalendarError, +// the way every Google Calendar call does: 409 google_reconnect_required when +// Google refused the stored grant (recorded as reconnect required, so only +// connecting again helps), 503 google_unavailable, retryable, when Google +// answered every try with HTTP 429 or 5xx (nothing was disconnected), and +// 502 google_failed, with failed as its message, for any other failure. +func writeGoogleFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error, failed string) { + logger.WarnContext(r.Context(), action, "error", err) + var calendar *gcal.CalendarError + switch { + case errors.Is(err, gcal.ErrReconnect): + writeError(w, r, logger, http.StatusConflict, "google_reconnect_required", + "Google access was revoked or expired; connect Google Calendar again") + case errors.As(err, &calendar) && calendar.Retryable(): + writeAPIError(w, r, logger, http.StatusServiceUnavailable, apiError{Code: "google_unavailable", + Message: "Google Calendar is busy right now; try again in a few minutes", Retryable: true}) + default: + writeError(w, r, logger, http.StatusBadGateway, "google_failed", failed) + } +} + +// getGoogle answers the Google Calendar connection's status: {status, +// calendar_id, calendar_name, connected_at, last_sync_error}, where status +// is not_connected, connected or reconnect_required. +func getGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + st, err := svc.Status(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "read Google Calendar status", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, st) + } +} diff --git a/internal/server/google_test.go b/internal/server/google_test.go new file mode 100644 index 0000000..3035ec5 --- /dev/null +++ b/internal/server/google_test.go @@ -0,0 +1,537 @@ +package server + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "testing" + "testing/fstest" + + "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" +) + +const ( + googleConnectPath = "/api/v1/integrations/google/connect" + googleCallbackPath = "/api/v1/integrations/google/callback" + googlePath = "/api/v1/integrations/google" + googleCalendarsPath = "/api/v1/integrations/google/calendars" + googleCalendarPath = "/api/v1/integrations/google/calendar" +) + +// googleEnv is a router whose Google Calendar service talks to a fake +// Google, with every log line it writes. +type googleEnv struct { + h http.Handler + google *gcaltest.Google + st *store.Store + box *secretbox.Box + logs *bytes.Buffer + // seen collects every response body and header, to check none holds a + // secret. + seen strings.Builder +} + +func newGoogleEnv(t *testing.T) *googleEnv { + t.Helper() + pub, err := url.Parse("https://mealquest.example") + if err != nil { + t.Fatal(err) + } + e := &googleEnv{ + google: gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath), + st: newTestStore(t), + box: newTestBox(t), + logs: &bytes.Buffer{}, + } + svc, err := gcal.NewService(e.st, e.box, gcal.Config{ + ClientID: gcaltest.ClientID, + ClientSecret: gcaltest.ClientSecret, + PublicURL: pub, + SecretKey: []byte("server-secret-key"), + Endpoint: e.google.Endpoint(), + RevokeURL: e.google.RevokeURL(), + CalendarAPI: e.google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t), + fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} { + if strings.Contains(e.seen.String(), secret) { + t.Errorf("a response holds a secret: %s", e.seen.String()) + } + if strings.Contains(e.logs.String(), secret) { + t.Errorf("the logs hold a secret: %s", e.logs.String()) + } + } + }) + return e +} + +func (e *googleEnv) serve(req *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + e.seen.WriteString(rec.Body.String()) + for k, vs := range rec.Header() { + e.seen.WriteString(k + ": " + strings.Join(vs, ", ") + "\n") + } + return rec +} + +// connect starts a connect as the test session and returns Google's consent +// URL and the connect cookie. +func (e *googleEnv) connect(t *testing.T) (*url.URL, *http.Cookie) { + t.Helper() + rec := e.serve(signedIn(http.MethodGet, googleConnectPath, nil)) + if rec.Code != http.StatusFound { + t.Fatalf("connect = %d %s, want 302", rec.Code, rec.Body) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("connect Cache-Control = %q, want no-store", cc) + } + consent, err := url.Parse(rec.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + var cookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == "__Host-mealquest_google" { + cookie = c + } + } + if cookie == nil { + t.Fatal("connect set no connect cookie") + } + return consent, cookie +} + +// callback returns to the callback as the test session with the connect +// cookie and query. +func (e *googleEnv) callback(cookie *http.Cookie, query url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, googleCallbackPath+"?"+query.Encode(), nil) + req.AddCookie(validSessionCookie) + if cookie != nil { + req.AddCookie(cookie) + } + return e.serve(req) +} + +func (e *googleEnv) consented(consent *url.URL) url.Values { + q := consent.Query() + return url.Values{"state": {q.Get("state")}, "code": {e.google.Authorize(q.Get("code_challenge"))}} +} + +func (e *googleEnv) stored(t *testing.T) bool { + t.Helper() + _, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()) + if errors.Is(err, sql.ErrNoRows) { + return false + } + if err != nil { + t.Fatal(err) + } + return true +} + +func errorBodyCode(t *testing.T, rec *httptest.ResponseRecorder) (code, message string) { + t.Helper() + var body struct { + Error apiError `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return body.Error.Code, body.Error.Message +} + +func TestGoogleConnectAndDisconnect(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + + consent, cookie := e.connect(t) + q := consent.Query() + if consent.Host != strings.TrimPrefix(e.google.Server.URL, "http://") || consent.Path != "/auth" { + t.Errorf("consent URL = %s, want the fake's /auth", consent) + } + if q.Get("access_type") != "offline" || q.Get("prompt") != "consent" || + q.Get("scope") != strings.Join(gcal.Scopes, " ") || + q.Get("redirect_uri") != "https://mealquest.example"+gcal.CallbackPath { + t.Errorf("consent query = %v", q) + } + + rec := e.callback(cookie, e.consented(consent)) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings/calendar" { + t.Fatalf("callback = %d Location %q %s, want 303 to /settings/calendar", rec.Code, rec.Header().Get("Location"), rec.Body) + } + row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()) + if err != nil { + t.Fatal(err) + } + if plain, err := e.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken { + t.Errorf("stored token opens to %q, %v", plain, err) + } + if row.ConnectedAt == "" { + t.Error("connected_at not set") + } + + // DELETE needs the CSRF header like every other write. + req := httptest.NewRequest(http.MethodDelete, googlePath, nil) + req.AddCookie(validSessionCookie) + if rec := e.serve(req); rec.Code != http.StatusForbidden { + t.Errorf("DELETE without CSRF = %d, want 403", rec.Code) + } + if len(e.google.Revoked()) != 0 || !e.stored(t) { + t.Fatal("a DELETE without CSRF revoked or cleared the connection") + } + + rec = e.serve(signedIn(http.MethodDelete, googlePath, nil)) + if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Fatalf("DELETE = %d %q, want 204 with no body", rec.Code, rec.Body) + } + if got := e.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) { + t.Errorf("revoked %q, want the refresh token", got) + } + if e.stored(t) { + t.Error("connection still stored after DELETE") + } + + rec = e.serve(signedIn(http.MethodDelete, googlePath, nil)) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusConflict || code != "not_connected" { + t.Errorf("second DELETE = %d %s, want 409 not_connected", rec.Code, rec.Body) + } +} + +func TestGoogleCallbackBadState(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + consent, cookie := e.connect(t) + good := e.consented(consent) + + for name, tc := range map[string]struct { + cookie *http.Cookie + query url.Values + }{ + "missing state": {cookie, url.Values{"code": good["code"]}}, + "wrong state": {cookie, url.Values{"state": {"forged"}, "code": good["code"]}}, + "missing cookie": {nil, good}, + } { + rec := e.callback(tc.cookie, tc.query) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_state" { + t.Errorf("%s: callback = %d %s, want 400 invalid_state", name, rec.Code, rec.Body) + } + } + if e.google.TokenCalls() != 0 { + t.Error("a code was exchanged despite a bad state") + } + if e.stored(t) { + t.Error("a connection was stored") + } +} + +func TestGoogleCallbackWithoutRefreshTokenAsksToReconnect(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.google.OmitRefreshToken() + consent, cookie := e.connect(t) + rec := e.callback(cookie, e.consented(consent)) + code, message := errorBodyCode(t, rec) + if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") { + t.Errorf("callback = %d %s, want 502 reconnect_required asking to connect again", rec.Code, rec.Body) + } + if e.stored(t) { + t.Error("a connection was stored without a refresh token") + } +} + +func TestGoogleCallbackFailuresAreRedacted(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.google.FailToken(http.StatusBadRequest) + consent, cookie := e.connect(t) + rec := e.callback(cookie, e.consented(consent)) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" { + t.Errorf("callback = %d %s, want 502 google_failed", rec.Code, rec.Body) + } + // newGoogleEnv's cleanup checks the response and logs hold no secret. +} + +func TestGoogleRevokeFailureKeepsConnection(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + 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.AnswerRevoke(http.StatusServiceUnavailable, "") + rec := e.serve(signedIn(http.MethodDelete, googlePath, nil)) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "revoke_failed" { + t.Errorf("DELETE = %d %s, want 502 revoke_failed", rec.Code, rec.Body) + } + if !e.stored(t) { + t.Error("connection cleared although Google did not revoke") + } +} + +func TestGoogleRoutesRequireSession(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + for _, c := range []struct{ method, path string }{ + {http.MethodGet, googleConnectPath}, + {http.MethodGet, googleCallbackPath + "?state=s&code=c"}, + {http.MethodDelete, googlePath}, + {http.MethodGet, googleCalendarsPath}, + {http.MethodPut, googleCalendarPath}, + } { + rec := e.serve(httptest.NewRequest(c.method, c.path, nil)) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON { + t.Errorf("%s %s signed out = %d %s, want 401", c.method, c.path, rec.Code, rec.Body) + } + } + if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 || e.google.CalendarCalls() != 0 { + t.Error("a signed-out request reached Google") + } +} + +// connected connects the test session's Google account and has the fake +// list an owned primary calendar, a writable shared one and a read-only one. +func (e *googleEnv) connected(t *testing.T) { + t.Helper() + consent, cookie := e.connect(t) + if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther { + t.Fatalf("callback = %d %s", rec.Code, rec.Body) + } + e.google.SetCalendars(0, + gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"}, + gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", Description: "private notes"}, + gcaltest.Calendar{ID: "holidays@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"}, + ) +} + +func chooseCalendar(id string) *http.Request { + return signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"`+id+`"}`)) +} + +func TestGoogleCalendarsListAndChoose(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + + rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil)) + want := `[{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` + + `{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"}]` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("GET calendars = %d %s, want 200 %s", rec.Code, rec.Body, want) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", cc) + } + + // PUT needs the CSRF header like every other write. + req := httptest.NewRequest(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"family@group.calendar.google.com"}`)) + req.AddCookie(validSessionCookie) + if rec := e.serve(req); rec.Code != http.StatusForbidden { + t.Errorf("PUT without CSRF = %d, want 403", rec.Code) + } + if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.Valid { + t.Fatal("a PUT without CSRF chose a calendar") + } + + rec = e.serve(chooseCalendar("family@group.calendar.google.com")) + if want := `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Family"}`; rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("PUT calendar = %d %s, want 200 %s", rec.Code, rec.Body, want) + } + row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()) + if err != nil { + t.Fatal(err) + } + if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" { + t.Errorf("stored calendar = %v %v", row.CalendarID, row.CalendarName) + } + + for _, id := range []string{"holidays@group.v.calendar.google.com", "nobody@group.calendar.google.com"} { + rec := e.serve(chooseCalendar(id)) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusUnprocessableEntity || code != "unknown_calendar" { + t.Errorf("PUT %s = %d %s, want 422 unknown_calendar", id, rec.Code, rec.Body) + } + } + if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.String != "family@group.calendar.google.com" { + t.Errorf("a refused choice replaced the stored calendar with %v", row.CalendarID) + } +} + +func TestGoogleCalendarChoiceBadRequest(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + calls := e.google.CalendarCalls() + for _, body := range []string{``, `{}`, `{"calendar_id":""}`, `{"calendar_id":null}`, `{"calendar_id":7}`, + `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Mine"}`} { + rec := e.serve(signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(body))) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_request" { + t.Errorf("PUT %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body) + } + } + if e.google.CalendarCalls() != calls { + t.Error("a bad request reached Google") + } +} + +func TestGoogleCalendarsNotConnected(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + for _, req := range []*http.Request{ + signedIn(http.MethodGet, googleCalendarsPath, nil), + chooseCalendar("family@group.calendar.google.com"), + } { + rec := e.serve(req) + code, message := errorBodyCode(t, rec) + if rec.Code != http.StatusConflict || code != "not_connected" || message != "Google Calendar is not connected" { + t.Errorf("%s %s = %d %s, want 409 not_connected", req.Method, req.URL.Path, rec.Code, rec.Body) + } + } + if e.google.TokenCalls() != 0 || e.google.CalendarCalls() != 0 { + t.Error("Google was called with nothing connected") + } +} + +// getGoogleStatus answers GET /integrations/google, decoded. +func (e *googleEnv) getGoogleStatus(t *testing.T) map[string]any { + t.Helper() + rec := e.serve(signedIn(http.MethodGet, googlePath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d %s, want 200", googlePath, rec.Code, rec.Body) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("GET %s Cache-Control = %q, want no-store", googlePath, cc) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body +} + +// TestGoogleCalendarsGrantRevoked lists and chooses after Google stopped +// accepting MealQuest's grant — the household removed its access in their +// Google account (invalid_grant), or Google refuses the access token just +// refreshed (HTTP 401): 409 google_reconnect_required, the refresh token is +// cleared, and GET /integrations/google reports reconnect_required with the +// reason, keeping the chosen calendar. +func TestGoogleCalendarsGrantRevoked(t *testing.T) { + t.Parallel() + for name, refuse := range map[string]func(*gcaltest.Google){ + "invalid_grant": (*gcaltest.Google).RevokeGrant, + "401 after a refresh": (*gcaltest.Google).RejectAccessToken, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + if rec := e.serve(chooseCalendar("family@group.calendar.google.com")); rec.Code != http.StatusOK { + t.Fatalf("PUT calendar = %d %s", rec.Code, rec.Body) + } + refuse(e.google) + for _, req := range []*http.Request{ + signedIn(http.MethodGet, googleCalendarsPath, nil), + chooseCalendar("household@gmail.com"), + } { + rec := e.serve(req) + code, message := errorBodyCode(t, rec) + if rec.Code != http.StatusConflict || code != "google_reconnect_required" || !strings.Contains(message, "connect Google Calendar again") { + t.Errorf("%s %s = %d %s, want 409 google_reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body) + } + } + got := e.getGoogleStatus(t) + if got["status"] != "reconnect_required" || got["last_sync_error"] != "Google access was revoked or expired — reconnect" || + got["calendar_id"] != "family@group.calendar.google.com" { + t.Errorf("GET %s = %v, want reconnect_required with the reason and the calendar kept", googlePath, got) + } + row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()) + if err != nil || row.RefreshTokenCt != nil { + t.Errorf("connection = %+v, %v; want it kept without a refresh token", row, err) + } + // newGoogleEnv's cleanup checks the responses and logs hold no secret. + }) + } +} + +// TestGoogleCalendarsTransientFailure fails the calendar list with HTTP 503 +// on every try: it is tried three times, then 503 google_unavailable, +// retryable, and the connection stays connected. +func TestGoogleCalendarsTransientFailure(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + e.google.FailCalendarList(http.StatusServiceUnavailable) + calls := e.google.CalendarCalls() + rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil)) + var body struct { + Error apiError `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusServiceUnavailable || body.Error.Code != "google_unavailable" || !body.Error.Retryable { + t.Errorf("GET calendars = %d %s, want 503 google_unavailable, retryable", rec.Code, rec.Body) + } + if n := e.google.CalendarCalls() - calls; n != 3 { + t.Errorf("calendar list tried %d times, want 3", n) + } + if got := e.getGoogleStatus(t); got["status"] != "connected" || got["last_sync_error"] != nil { + t.Errorf("GET %s = %v, want connected with no error", googlePath, got) + } + if !e.stored(t) { + t.Error("the connection was cleared") + } + // newGoogleEnv's cleanup checks the response and logs hold no secret. +} + +func TestGoogleStatus(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + if got := e.getGoogleStatus(t); got["status"] != "not_connected" { + t.Errorf("GET %s = %v, want not_connected", googlePath, got) + } + e.connected(t) + got := e.getGoogleStatus(t) + if got["status"] != "connected" || got["connected_at"] == nil || got["calendar_id"] != nil || got["last_sync_error"] != nil { + t.Errorf("GET %s = %v, want connected with no calendar", googlePath, got) + } + if len(got) != 5 { + t.Errorf("GET %s = %v, want exactly status, calendar_id, calendar_name, connected_at and last_sync_error", googlePath, got) + } +} + +func TestGoogleCalendarsFailureIsRedacted(t *testing.T) { + t.Parallel() + e := newGoogleEnv(t) + e.connected(t) + e.google.FailCalendarList(http.StatusForbidden) + rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil)) + if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" { + t.Errorf("GET calendars = %d %s, want 502 google_failed", rec.Code, rec.Body) + } + if !strings.Contains(e.logs.String(), "HTTP 403") { + t.Errorf("logs = %s, want the failure's status", e.logs) + } + // newGoogleEnv's cleanup checks the response and logs hold no secret. +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 16d854e..3555347 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index dad2c2d..d301ace 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 8c3f22b..dfe3b5c 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans.go b/internal/server/plans.go index b15d345..cf81dc6 100644 --- a/internal/server/plans.go +++ b/internal/server/plans.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -93,6 +94,52 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { } } +// postPlanPush pushes the week's plan to the chosen Google calendar +// (Push to Calendar) and answers with the plan as synced. +func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + p, err := svc.Push(r.Context(), week) + if err != nil { + writePlanPushError(w, r, logger, err) + return + } + writeJSON(w, r, logger, http.StatusOK, p) + } +} + +// writePlanPushError maps a Push to Calendar error to its API error: 409 +// not_connected when Google Calendar is not connected, no_calendar when no +// calendar is chosen, calendar_unavailable when the chosen calendar was +// deleted or is no longer writable, and plan_changed when the plan was +// edited during the push; a Google failure as writeGoogleFailure maps it +// (409 google_reconnect_required, 503 google_unavailable or 502 +// google_failed). A failed push leaves the plan's status as it was. Any other +// error is the plan's, as writePlanError maps it. +func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) { + var calendar *gcal.CalendarError + 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.ErrNoCalendar): + writeError(w, r, logger, http.StatusConflict, "no_calendar", + "choose the calendar to push dinners to in Settings, then push again") + case errors.Is(err, gcal.ErrCalendarUnavailable): + logger.WarnContext(r.Context(), "push plan to Calendar", "error", err) + writeError(w, r, logger, http.StatusConflict, "calendar_unavailable", + "the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings") + case errors.Is(err, plan.ErrPlanChanged): + writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error()) + case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar): + writeGoogleFailure(w, r, logger, "push plan to Calendar", err, "Google Calendar could not be reached; push again") + default: + writePlanError(w, r, logger, "push plan to Calendar", err) + } +} + // weekStartOrFail parses the {weekStart} path parameter, or answers 400 // when it is not a Monday date. func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) { diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 9f6142c..1b67833 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -1,14 +1,15 @@ package server import ( + "bytes" "context" "database/sql" "encoding/json" "errors" - "io" "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "sync" "testing" @@ -17,6 +18,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "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/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -34,6 +37,12 @@ type plansEnv struct { st *store.Store svc *plan.Service questLog string + // google is the fake Google the plan service pushes to; it starts + // unconnected. + google *gcaltest.Google + gcal *gcal.Service + // logs holds every log line the router writes. + logs *bytes.Buffer } func newPlansRouter(t *testing.T) plansEnv { @@ -43,7 +52,8 @@ func newPlansRouter(t *testing.T) plansEnv { if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { t.Fatal(err) } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + logs := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) runner, err := jobs.NewRunner(st, logger, 1) if err != nil { t.Fatal(err) @@ -51,12 +61,26 @@ func newPlansRouter(t *testing.T) plansEnv { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, logger) + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath) + googleSvc, err := gcal.NewService(st, newTestBox(t), 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) + } + svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger) if err != nil { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } @@ -90,7 +114,8 @@ func newPlansRouter(t *testing.T) plansEnv { t.Fatal(err) } } - return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)} + return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), + google: google, gcal: googleSvc, logs: logs} } func (e plansEnv) setStatus(t *testing.T, status string) { @@ -324,3 +349,182 @@ func TestPutPlanDay(t *testing.T) { t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body) } } + +// pushCalendar is the calendar the push tests choose. +var pushCalendar = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"} + +// connectCalendar connects the fake Google and, when choose, chooses +// pushCalendar. +func (e plansEnv) connectCalendar(t *testing.T, choose bool) { + t.Helper() + e.google.SetCalendars(0, pushCalendar) + e.google.Connect(t, e.gcal) + if !choose { + return + } + if _, err := e.gcal.SelectCalendar(t.Context(), pushCalendar.ID); err != nil { + t.Fatal(err) + } +} + +// push calls POST /plans/{weekStart}/push-calendar and checks that neither +// the response nor any log line holds a secret. +func (e plansEnv) push(t *testing.T, weekStart string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + rec, body := callDinners(t, e.h, http.MethodPost, plansPath+weekStart+"/push-calendar", "") + for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} { + if strings.Contains(rec.Body.String(), secret) { + t.Errorf("response holds a secret: %s", rec.Body) + } + if strings.Contains(e.logs.String(), secret) { + t.Errorf("logs hold a secret: %s", e.logs) + } + } + return rec, body +} + +func TestPushPlanToCalendar(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.connectCalendar(t, true) + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusOK { + t.Fatalf("push = %d: %s", rec.Code, rec.Body) + } + if body["status"] != "synced" || body["calendar_synced_at"] == nil { + t.Errorf("pushed plan = %v, want synced", body) + } + if days, _ := body["days"].([]any); len(days) != 7 { + t.Errorf("days = %v, want 7", body["days"]) + } + if n := len(e.google.Events(pushCalendar.ID)); n != 7 { + t.Errorf("calendar has %d events, want 7", n) + } + monday := e.google.Events(pushCalendar.ID)[plan.EventID(mustLocalDate(t, "2026-10-12"), mustLocalDate(t, "2026-10-12"))] + if !strings.HasPrefix(monday.Description, "Beef Chili\nStart by 1:30 pm\nhttps://mealquest.example/dinner/") { + t.Errorf("Monday's description = %q", monday.Description) + } +} + +func TestPushPlanToCalendarErrors(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + week string + setup func(t *testing.T, e plansEnv) + status int + code string + }{ + {"not connected", "2026-10-12", func(*testing.T, plansEnv) {}, http.StatusConflict, "not_connected"}, + {"no calendar chosen", "2026-10-12", func(t *testing.T, e plansEnv) { e.connectCalendar(t, false) }, http.StatusConflict, "no_calendar"}, + {"calendar deleted", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.SetCalendars(0) + }, http.StatusConflict, "calendar_unavailable"}, + {"calendar read-only", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.SetCalendars(0, gcaltest.Calendar{ID: pushCalendar.ID, Summary: "Dinners", AccessRole: "reader"}) + }, http.StatusConflict, "calendar_unavailable"}, + {"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.RevokeGrant() + }, http.StatusConflict, "google_reconnect_required"}, + {"401 after a refresh", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.RejectAccessToken() + }, http.StatusConflict, "google_reconnect_required"}, + {"Google unavailable", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.FailEventWrites(3, 3, http.StatusServiceUnavailable) + }, http.StatusServiceUnavailable, "google_unavailable"}, + {"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.FailEventWrite(3, http.StatusBadRequest) + }, http.StatusBadGateway, "google_failed"}, + {"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"}, + {"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + tc.setup(t, e) + rec, body := e.push(t, tc.week) + if rec.Code != tc.status || errorCode(body) != tc.code { + t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code) + } + if apiErr, _ := body["error"].(map[string]any); apiErr["retryable"] != (tc.code == "google_unavailable") { + t.Errorf("push = %s, want retryable only when Google is unavailable", rec.Body) + } + if tc.code == "google_reconnect_required" { + _, status := callDinners(t, e.h, http.MethodGet, googlePath, "") + if status["status"] != "reconnect_required" || status["last_sync_error"] != gcal.ReconnectReason { + t.Errorf("GET %s after the push = %v, want reconnect_required with the reason", googlePath, status) + } + } + p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12")) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusDraft || p.CalendarSyncedAt != nil { + t.Errorf("plan = %s synced at %v after a failed push, want draft", p.Status, p.CalendarSyncedAt) + } + }) + } +} + +// TestPushPlanAfterRefusalWithNoCalendar pushes after Google refused the +// grant while listing calendars, before any was chosen: the push is 409 +// google_reconnect_required, not no_calendar, since only connecting again +// restores it. After connecting again and choosing a calendar it succeeds. +func TestPushPlanAfterRefusalWithNoCalendar(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.connectCalendar(t, false) + e.google.RevokeGrant() + if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Calendars = %v, want ErrReconnect", err) + } + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusConflict || errorCode(body) != "google_reconnect_required" { + t.Errorf("push = %d %s, want 409 google_reconnect_required", rec.Code, rec.Body) + } + + e.connectCalendar(t, true) + if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusOK { + t.Errorf("push after connecting again = %d: %s", rec.Code, rec.Body) + } +} + +func TestPushPlanChangedWhilePushing(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.connectCalendar(t, true) + arrived, release := e.google.HoldEvents() + defer release() + pushed := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, signedIn(http.MethodPost, plansPath+"2026-10-12/push-calendar", nil)) + pushed <- rec + }() + <-arrived + if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil { + t.Fatal(err) + } + release() + rec := <-pushed + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"plan_changed"`) { + t.Errorf("push = %d %s, want 409 plan_changed", rec.Code, rec.Body) + } +} + +func mustLocalDate(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 9931bb6..34f2032 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 5fbceb6..5de1285 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -45,12 +46,15 @@ type RouteMounter interface { // /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST // /api/v1/plans/{weekStart}/draft, PUT // /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and -// POST /api/v1/dinners/{id}/rotation-review; runner serves +// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET +// /api/v1/integrations/google and its /connect, /callback and /calendars, +// PUT /api/v1/integrations/google/calendar and DELETE +// /api/v1/integrations/google; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -69,6 +73,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if planSvc == nil { return nil, errors.New("server: nil plan service") } + if googleSvc == nil { + return nil, errors.New("server: nil Google Calendar service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -126,8 +133,18 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc)) apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc)) + apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc)) apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) + // connect and callback are top-level browser navigations, so they are + // GETs: the Lax session cookie rides both and CSRF applies to neither. + // The callback's state is bound to the session that started connect. + apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc)) + apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc)) + apiV1.Get("/integrations/google", getGoogle(logger, googleSvc)) + apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc)) + apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc)) + apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a7499a3..4ede01b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -11,16 +11,19 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "testing/fstest" "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -50,7 +53,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -199,7 +202,7 @@ func newTestPlan(t *testing.T) *plan.Service { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, logger) + svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } @@ -218,6 +221,29 @@ func newTestBox(t *testing.T) *secretbox.Box { return box } +// newTestGoogle returns a Google Calendar service over a migrated temp +// database whose Google endpoints refuse every connection. +func newTestGoogle(t *testing.T) *gcal.Service { + t.Helper() + pub, err := url.Parse("https://mealquest.example") + if err != nil { + t.Fatal(err) + } + svc, err := gcal.NewService(newTestStore(t), newTestBox(t), gcal.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + PublicURL: pub, + SecretKey: []byte("server-secret-key"), + Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"}, + RevokeURL: "http://127.0.0.1:1/revoke", + CalendarAPI: "http://127.0.0.1:1/calendar/v3", + }) + if err != nil { + t.Fatal(err) + } + return svc +} + // newTestAI returns an AI settings service over a migrated temp database // with the seed AI settings and a random secret box. func newTestAI(t *testing.T) *aisettings.Service { @@ -237,7 +263,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -246,7 +272,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -255,7 +281,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -264,7 +290,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -273,7 +299,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -282,7 +308,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -291,16 +317,25 @@ func TestNewRouterRequiresPlan(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } +func TestNewRouterRequiresGoogle(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil Google Calendar service") + } +} + func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 09f1be7..8a4398f 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index a0c6549..2595b13 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 5533fdc..4349f60 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got) } } @@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -718,3 +718,46 @@ func TestMigrateRotationReviewName(t *testing.T) { t.Error("rotation_reviews_copy left behind") } } + +// TestMigrateGoogleReconnectKeepsConnection applies 0015, which rebuilds +// integrations_google, over a stored connection: every column survives. +func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + matches, err := fs.Glob(migrations.FS, "*.sql") + if err != nil { + t.Fatal(err) + } + for _, name := range matches { + if name >= "0015" { + continue + } + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + const row = `1|token|family@group.calendar.google.com|Family|2026-10-01T00:00:00.000Z|earlier error|2026-09-01T00:00:00.000Z|2026-10-02T00:00:00.000Z` + if _, err := s.Writer.ExecContext(ctx, `INSERT INTO integrations_google VALUES (1, CAST('token' AS BLOB), + 'family@group.calendar.google.com', 'Family', '2026-10-01T00:00:00.000Z', 'earlier error', + '2026-09-01T00:00:00.000Z', '2026-10-02T00:00:00.000Z')`); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + var got string + if err := s.Reader.QueryRowContext(ctx, `SELECT id || '|' || CAST(refresh_token_ct AS TEXT) || '|' || calendar_id || '|' || + calendar_name || '|' || connected_at || '|' || last_sync_error || '|' || created_at || '|' || updated_at + FROM integrations_google`).Scan(&got); err != nil { + t.Fatal(err) + } + if got != row { + t.Errorf("connection after 0015 = %s, want %s", got, row) + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 510a752..8750a2a 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -516,6 +516,24 @@ func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) { } } +// TestSchemaGoogleTokenClearedOnlyWithReason: a Google connection may lose +// its refresh token (reconnect required) only with last_sync_error saying +// why. +func TestSchemaGoogleTokenClearedOnlyWithReason(t *testing.T) { + s := migratedStore(t) + row := testRow("integrations_google", 0) + row["refresh_token_ct"] = nil + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, "integrations_google", row), "CHECK", "integrations_google without a token or a reason") + }) + row["last_sync_error"] = "Google access was revoked or expired — reconnect" + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, "integrations_google", row); err != nil { + t.Errorf("integrations_google without a token, with a reason: %v", err) + } + }) +} + func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) { cases := []struct{ parent, child, column string }{ {"recipes", "recipe_ingredients", "recipe_id"}, diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 469579a..cac527e 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "net/url" "slices" "strings" "sync" @@ -19,6 +20,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -927,7 +929,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no draft in this test") } - planSvc, err := plan.NewService(e.st, fail, e.runner, logger) + planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) if err != nil { t.Fatal(err) } @@ -1320,3 +1322,10 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) { t.Fatal("unknown trigger accepted") } } + +// noCalendar is a plan.Calendar for tests that never push. +type noCalendar struct{} + +func (noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error { + return errors.New("no calendar in this test") +} diff --git a/migrations/0015_google_reconnect_required.sql b/migrations/0015_google_reconnect_required.sql new file mode 100644 index 0000000..7b62bb6 --- /dev/null +++ b/migrations/0015_google_reconnect_required.sql @@ -0,0 +1,31 @@ +-- 0015_google_reconnect_required: when Google refuses the stored grant +-- (invalid_grant on refresh, or HTTP 401 from the Calendar API) the +-- connection is kept as reconnect required: its refresh token is cleared +-- and last_sync_error says why. SQLite cannot drop a NOT NULL constraint, +-- so the table is rebuilt with refresh_token_ct nullable. A row without a +-- refresh token always carries the reason. No table references +-- integrations_google. + +CREATE TABLE integrations_google_new ( + id INTEGER PRIMARY KEY CHECK (id = 1), + refresh_token_ct BLOB, + calendar_id TEXT, + calendar_name TEXT, + connected_at TEXT NOT NULL, + last_sync_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (refresh_token_ct IS NOT NULL OR last_sync_error IS NOT NULL) +); + +INSERT INTO integrations_google_new ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) +SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +FROM integrations_google; + +DROP TABLE integrations_google; + +ALTER TABLE integrations_google_new RENAME TO integrations_google; diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql index c64d7f0..369ec03 100644 --- a/migrations/queries/plans.sql +++ b/migrations/queries/plans.sql @@ -7,7 +7,7 @@ WHERE week_start = ?; -- name: ListPlanDays :many -- The days of plan plan_id with their dinners, Monday first. A day with -- no dinner is left out. -SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners) +SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, sqlc.embed(dinners) FROM plan_days JOIN dinners ON dinners.id = plan_days.dinner_id WHERE plan_days.plan_id = ? @@ -25,20 +25,59 @@ SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ? WHERE id = ?; -- name: PutPlanDay :exec --- Plans dinner_id on date of plan_id, keeping the day's calendar event id --- when the day exists. +-- Plans dinner_id on date of plan_id. An existing day keeps its calendar +-- event id while its dinner stays the same; a new dinner clears it, since +-- Calendar no longer shows the day as planned. INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (plan_id, date) DO UPDATE -SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at; +SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, + calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END, + updated_at = excluded.updated_at; -- name: SwapPlanDay :execrows --- Plans dinner_id on an existing day of plan_id; a date the plan does not --- have is left as it is (0 rows). +-- Plans dinner_id on an existing day of plan_id, clearing its calendar +-- event id since Calendar no longer shows the day as planned; a date the +-- plan does not have is left as it is (0 rows). UPDATE plan_days -SET dinner_id = ?, is_new = ?, updated_at = ? +SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ? WHERE plan_id = ? AND date = ?; +-- name: SetPlanDayCalendarEvent :execrows +-- Records that the event calendar_event_id on Calendar shows date of +-- plan_id as planned. +UPDATE plan_days +SET calendar_event_id = ?, updated_at = ? +WHERE plan_id = ? AND date = ?; + +-- name: MarkWeekPlanSynced :execrows +-- Records a Push to Calendar of plan id as it was at revision: the plan +-- becomes synced. A plan edited since (another revision) is left as it is +-- (0 rows). +UPDATE week_plans +SET status = 'synced', calendar_synced_at = ?, updated_at = ? +WHERE id = ? AND revision = ?; + +-- name: MarkCalendarPlansEdited :exec +-- Records that Calendar no longer shows the days with a calendar event id +-- as planned, since another calendar or none is chosen: every plan with +-- such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes +-- changed and a push that read it before is refused. +UPDATE week_plans +SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, + revision = revision + 1, + updated_at = ? +WHERE EXISTS ( + SELECT 1 FROM plan_days + WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL +); + +-- name: ClearCalendarEvents :exec +-- Clears every day's calendar event id: none is on the chosen calendar. +UPDATE plan_days +SET calendar_event_id = NULL, updated_at = ? +WHERE calendar_event_id IS NOT NULL; + -- name: MarkWeekPlanEdited :exec -- Records an edit of plan id, raising its revision: a synced plan becomes -- changed, any other keeps its status. diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index 6a1c6a8..d9983ec 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -106,6 +106,24 @@ ON CONFLICT (id) DO UPDATE SET updated_at = excluded.updated_at RETURNING *; +-- name: SetIntegrationsGoogleCalendar :execrows +-- Sets the chosen calendar; affects no row when nothing is connected. +UPDATE integrations_google +SET calendar_id = ?, calendar_name = ?, updated_at = ? +WHERE id = 1; + +-- name: ClearIntegrationsGoogleToken :execrows +-- Records that Google refuses the stored grant: clears the refresh token and +-- sets last_sync_error, keeping the chosen calendar. Affects no row when +-- nothing is connected. +UPDATE integrations_google +SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ? +WHERE id = 1; + +-- name: DeleteIntegrationsGoogle :exec +DELETE FROM integrations_google +WHERE id = 1; + -- name: GetChatSettings :one -- Returns sql.ErrNoRows until the row exists. SELECT * FROM chat_settings diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index abcaadf..a3dde74 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -36,6 +36,7 @@ const SCREENS = { '/settings/ai': true, '/settings/household': true, '/settings/regulars': true, + '/settings/calendar': true, } // SIGNED_IN answers what every route reads first: the session, then the @@ -353,4 +354,20 @@ describe('App offline bootstrap', () => { await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) + + it('/settings/calendar renders the Google Calendar screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/integrations/google': () => + jsonResponse(200, { status: 'not_connected', calendar_id: null, calendar_name: null, connected_at: null, last_sync_error: null }), + }) + renderAt('/settings/calendar') + const heading = await screen.findByRole('heading', { level: 1, name: 'Google Calendar' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toHaveAttribute( + 'href', + '/api/v1/integrations/google/connect', + ) + }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index da3b58a..de51b4f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { PlanPage } from './plan/PlanPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' +import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx' import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' @@ -43,6 +44,7 @@ const screens: Partial> = { '/settings/ai': , '/settings/household': , '/settings/regulars': , + '/settings/calendar': , } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 10af1ef..49acb3b 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -258,9 +258,9 @@ color: var(--color-neutral-700); } -/* An action with nothing behind it yet (Push to Calendar until Feature 16, - the shopping list until Feature 17): it keeps focus, so hovering, tapping - or focusing it shows the tooltip. */ +/* An action with nothing behind it yet (the shopping list until Feature + 17): it keeps focus, so hovering, tapping or focusing it shows the + tooltip. */ .unavailable { position: relative; display: flex; @@ -406,6 +406,21 @@ color: var(--color-accent-2-800); } +/* Under the Draft board's push button the line is centred and may wrap. */ +.mobileStatus { + flex: none; + justify-content: center; + white-space: normal; + text-align: center; +} + +/* A push failure's way to the Google Calendar settings. */ +.settingsLink { + color: inherit; + font-weight: 800; + text-decoration: underline; +} + .actions { display: flex; flex-wrap: wrap; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 9e48c68..f3d73bf 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -15,6 +15,7 @@ const THIS_WEEK = '/api/v1/plans/2026-10-05' const NEXT_WEEK = '/api/v1/plans/2026-10-12' const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28' const DRAFT = `${THIS_WEEK}/draft` +const PUSH = `${THIS_WEEK}/push-calendar` const JOB = '/api/v1/jobs/j1' const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] @@ -50,6 +51,21 @@ function planBody(status: string, names = NAMES) { } } +// syncedNow is the plan of the week of Oct 5 as a push answers it: synced +// at the tests' noon. +function syncedNow() { + return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + // draftJob is job j1 drafting the week of Oct 5 into plan planId: a // redraft keeps the plan's id. function draftJob(status: string, error: string | null = null, planId = 'plan-1') { @@ -225,17 +241,312 @@ describe('PlanPage', () => { expect(screen.queryByText('Draft')).not.toBeInTheDocument() }) - it('offers Push to Calendar unavailable, with a tooltip saying why', async () => { - const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + it('pushes the plan to Calendar, showing progress, then the plan as synced', async () => { + const answer = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => answer.response, + }) await renderPage() - const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' }) - expect(push).toHaveAttribute('aria-disabled', 'true') - expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') - expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.') + expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument() + const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) expect(declaredStyle(push).minHeight).toBe('50px') fireEvent.click(push) - expect(posts(fetchMock)).toEqual([]) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])) + const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')! + expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) + + // In progress: the button says so and is disabled, as is Rebuild week, + // and no day can be swapped. + const pushing = await screen.findByRole('button', { name: 'Pushing to Calendar…' }) + expect(pushing).toBeDisabled() + expect(pushing).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + answer.send(jsonResponse(200, syncedNow())) + const status = await screen.findByText('Synced to Google Calendar just now') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + expect(status.querySelector('svg')).not.toBeNull() + expect(screen.getByText('Synced')).toBeInTheDocument() + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + // The plan shown is the one the push answered with: no second read. + expect(planReads(fetchMock)).toBe(1) + }) + + it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')) }) + await renderPage() + + const status = await screen.findByText('Synced to Google Calendar 2 min ago') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + expect(status.querySelector('svg')).not.toBeNull() + }) + + it('says changes to a pushed plan are not pushed, and pushes them', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow()), + }) + await renderPage() + + expect(await screen.findByText('Changes not pushed')).toBeInTheDocument() + const status = screen.getByText('Changes not pushed to Google Calendar') + expect(declaredStyle(status).color).toBe('var(--color-neutral-700)') + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByText('Changes not pushed')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + }) + + it.each([ + ['google_reconnect_required', 'Google access was revoked or expired.', 'Reconnect Google Calendar'], + ['no_calendar', 'No calendar is chosen for dinners yet.', 'Choose a calendar in Settings'], + ['not_connected', 'Google Calendar is not connected.', 'Connect Google Calendar'], + ['calendar_unavailable', 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', 'Choose another calendar'], + ])('sends a %s push failure to the Google Calendar settings', async (code, reason, fix) => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => errorResponse(409, code), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(`Couldn't push to Calendar. ${reason} ${fix}`) + expect(within(alert).getByRole('link', { name: fix })).toHaveAttribute('href', '/settings/calendar') + expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + // The plan is as it was: not synced. + expect(screen.getByText('Draft')).toBeInTheDocument() + expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + }) + + it('marks the Google Calendar connection to be read again when Google refused the grant', async () => { + const connection = { status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners', connected_at: null, last_sync_error: null } + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + const queryClient = createQueryClient() + queryClient.setQueryData(['integrations', 'google'], connection) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'), + }) + await api.loadMe() + render( + + + + + + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('link', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() + // The settings screen reads it again as it opens: it is now reconnect + // required. + expect(queryClient.getQueryState(['integrations', 'google'])?.isInvalidated).toBe(true) + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + }) + + it('says why any other push failed, and pushes again on Retry', async () => { + let push = () => errorResponse(502, 'google_failed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. google_failed message") + expect(within(alert).queryByRole('link')).not.toBeInTheDocument() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('reads the plan again when it changed while being pushed', async () => { + let plan = planBody('draft') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${PUSH}`]: () => errorResponse(409, 'plan_changed'), + }) + await renderPage() + + await screen.findByText('Beef tacos') + plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't push to Calendar. The plan changed while it was being pushed; push it again.", + ) + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(planReads(fetchMock)).toBe(2) + }) + + it('pushes nothing again until the changed plan is read and shown', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) + fireEvent.click(pushButton) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + // While the changed plan is read, the push stays busy: neither Push + // nor Retry sends it again. + expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) + expect(screen.getByText('Beef tacos')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByText('Lasagna')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('pushes nothing again until the changed plan is shown, even after leaving the week and coming back', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + + // Leaving the week and coming back while the changed plan is read + // remounts the board: the push is still busy, and sends nothing again. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + await screen.findByText('Beef tacos') + expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByText('Lasagna')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it("pushes nothing again while the changed plan's read is answered from the cache", async () => { + let read = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + // The server can't be reached: the service worker answers the read of + // the changed plan with the plan it cached before. + read = () => { + const response = jsonResponse(200, planBody('draft')) + response.headers.set(cacheFallbackHeader, '1') + return response + } + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + const cachedCopy = "The plan changed while it was being pushed. The plan shown came from this device's cache, not the server; read it again before pushing." + const failed = await screen.findByRole('alert') + expect(planReads(fetchMock)).toBe(2) + expect(screen.getByText('Beef tacos')).toBeInTheDocument() + const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) + expect(pushButton).toBeDisabled() + fireEvent.click(pushButton) + expect(failed).toHaveTextContent(cachedCopy) + + // Retry reads the plan again rather than pushing: still the cache, so + // nothing is pushed. + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(3)) + expect(await screen.findByRole('alert')).toHaveTextContent(cachedCopy) + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeDisabled() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + // The server is back: Retry shows the changed plan, and only then may it + // be pushed. + read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('shows the changed plan read while the week was left, and pushes it', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) + read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + + // The board comes back on the changed plan: Push sends that one. + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('The plan changed while it was being pushed; push it again.') + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { @@ -715,20 +1026,19 @@ describe.each([ expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') - // Actions: the shopping list and Push to Calendar unavailable, saying - // why; Rebuild week outlined and working. + // Actions: the shopping list unavailable, saying why; Rebuild week + // outlined and Push to Calendar primary, both working. const shopping = screen.getByRole('button', { name: 'Shopping list' }) expect(shopping).toHaveAttribute('aria-disabled', 'true') expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.') const push = screen.getByRole('button', { name: 'Push to Calendar' }) - expect(push).toHaveAttribute('aria-disabled', 'true') - expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') + expect(push).toBeEnabled() + expect(push).not.toHaveAttribute('aria-disabled') expect(declaredStyle(push).background).toBe('var(--color-accent)') const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) expect(rebuild).toBeEnabled() expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') fireEvent.click(shopping) - fireEvent.click(push) expect(posts(fetchMock)).toEqual([]) // Mobile's own pieces are not shown. expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() @@ -795,6 +1105,43 @@ describe.each([ expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() }) + it('pushes the plan from the header, showing progress, then the plan as synced', async () => { + const answer = deferred() + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), + [`POST ${PUSH}`]: () => answer.response, + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' })) + const pushing = await screen.findByRole('button', { name: 'Pushing…' }) + expect(pushing).toBeDisabled() + expect(pushing).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + + answer.send(jsonResponse(200, syncedNow())) + const status = await screen.findByText('Synced to Google Calendar just now') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + expect(screen.getByRole('button', { name: 'Push to Calendar' })).toBeEnabled() + }) + + it('links a google_reconnect_required push failure to the Google Calendar settings', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), + [`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'), + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(within(alert).getByRole('link', { name: 'Reconnect Google Calendar' })).toHaveAttribute('href', '/settings/calendar') + expect(screen.getByText('Synced to Google Calendar 2 min ago')).toBeInTheDocument() + }) + if (layout === 'desktop') { it('shows the Quest log and Regulars in the 290 px right panel', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 3867949..4cf8667 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -1,7 +1,8 @@ import { useQueryClient } from '@tanstack/react-query' import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' -import { useJob } from '../api/index.ts' +import { Link } from 'react-router' +import { ApiError, useJob } from '../api/index.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' import { Confirm } from '../settings/LeaveGuard.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx' @@ -20,6 +21,7 @@ import { parseDraftResult, planKey, usePlan, + usePushToCalendar, useStartDraft, useSwapDay, type DraftResult, @@ -62,9 +64,20 @@ const draftFailures: Record = { internal_error: 'Something went wrong on the server.', } -// PUSH_HINT is why Push to Calendar is unavailable. -// TODO(Feature 16): connect Push to Calendar once Google Calendar is. -const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' +// CALENDAR_SETTINGS is the Google Calendar settings screen. +const CALENDAR_SETTINGS = '/settings/calendar' + +// settingsFailures says why a push failed, by its API error code, for the +// failures only the Google Calendar settings can fix, and what to do there. +const settingsFailures: Record = { + google_reconnect_required: { reason: 'Google access was revoked or expired.', fix: 'Reconnect Google Calendar' }, + not_connected: { reason: 'Google Calendar is not connected.', fix: 'Connect Google Calendar' }, + no_calendar: { reason: 'No calendar is chosen for dinners yet.', fix: 'Choose a calendar in Settings' }, + calendar_unavailable: { + reason: 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', + fix: 'Choose another calendar', + }, +} // SHOPPING_HINT is why the shopping list is unavailable. // TODO(Feature 17): open the Supply run, with its count, once it is built. @@ -204,9 +217,13 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre // the plan is read again. Rebuilding a plan pushed to Calendar asks first, // and sends confirm. A failed draft is shown with Retry; no plan is made up. // -// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the -// unavailable Shopping list and Push to Calendar are actions in the header, +// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to +// Calendar and the unavailable Shopping list are actions in the header, // over the plan's sync status and the 7-column grid. +// +// Push to Calendar, on either layout, sends the plan shown and shows it as +// synced once the server answers; a failure says why, linking to the Google +// Calendar settings when only they can fix it. function WeekBoard({ weekStart, timezone, @@ -225,6 +242,8 @@ function WeekBoard({ const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) const swap = useSwapDay(weekStart) + const push = usePushToCalendar(weekStart) + const pushing = push.pending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) @@ -282,13 +301,28 @@ function WeekBoard({ const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) + // A push sends the plan shown: it waits for a draft or a swap of the week + // to settle, and neither starts while it runs, even across leaving the + // week and coming back. Nor does it send a plan from this device's cache, + // which may not be the server's: after a push refused because the plan + // changed, the read of the changed plan may be answered from the cache. + const cached = plan.isSuccess && plan.data !== null && plan.data.cached + const canPush = plan.isSuccess && plan.data !== null && !cached && !drafting && !swap.pending && !pushing + function pushToCalendar() { + if (!canPush) return + swap.dismiss() + push.reset() + push.mutate() + } + const { mutate } = startDraft function start(confirm: boolean) { setConfirming(false) - // A draft asked for again leaves the last one's failure, and a failed - // swap's, behind. + // A draft asked for again leaves the last one's failure, a failed + // swap's and a failed push's behind. setJobId(null) swap.dismiss() + push.reset() mutate( { weekStart, confirm }, { @@ -302,12 +336,14 @@ function WeekBoard({ // It waits for a swap of the week still pending, which may yet fail: the // Rebuild week tile is disabled meanwhile. function rebuild() { - if (swap.pending) return + if (swap.pending || pushing) return if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) else start(false) } - const failure = swap.failed !== null ? ( + const failure = push.error !== null ? ( + void plan.refetch() : pushToCalendar} /> + ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} @@ -328,6 +364,7 @@ function WeekBoard({ // swapIn sends the swap chosen in the sheet and closes it. function swapIn(chosen: Swap) { setSwappingDate(null) + push.reset() swap.mutate(chosen) } // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, @@ -338,9 +375,10 @@ function WeekBoard({ } // openSwap opens the swap sheet on a day. One swap of the week is sent at // a time, even across leaving the week and coming back: the plan the - // server answers it with must not overwrite another still pending. + // server answers it with must not overwrite another still pending. Nor is + // one sent while the week is being pushed to Calendar. function openSwap(date: string) { - if (swap.pending) return + if (swap.pending || pushing) return swap.dismiss() setSwappingDate(date) } @@ -379,7 +417,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> - +
    {days.map((day) => ( ))}
  • -
- - Looks good — push to Calendar - + + {sheet} ) @@ -458,14 +497,19 @@ function WeekBoard({ Shopping list - - + @@ -507,10 +551,11 @@ function WeekBoard({ ) } -// SyncStatus is the plan manager's status line: whether the plan is on -// Google Calendar and, once synced, how long ago it was pushed, kept current -// minute by minute. -function SyncStatus({ plan }: { plan: Plan }) { +// SyncStatus is the plan's status line, in the plan manager's header and +// under the Draft board's push button: whether the plan is on Google +// Calendar and, once synced, how long ago it was pushed, kept current minute +// by minute. +function SyncStatus({ plan, className }: { plan: Plan; className?: string }) { const { status, calendar_synced_at: syncedAt } = plan const [now, setNow] = useState(() => new Date()) useEffect(() => { @@ -518,7 +563,7 @@ function SyncStatus({ plan }: { plan: Plan }) { return () => clearInterval(timer) }, []) return ( -

+

{status === 'synced' && } {syncStatuses[status]} {status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`} @@ -526,9 +571,38 @@ function SyncStatus({ plan }: { plan: Plan }) { ) } -// Unavailable is an action with nothing behind it yet (Push to Calendar, the -// shopping list): it stays focusable, so a tap or focus shows the tooltip -// saying why, above it or, at the top of the page, below. +// PushFailure says why Push to Calendar failed. A failure only the Google +// Calendar settings can fix links there; any other offers to push again, +// or, while the plan shown is this device's cached copy, to read it again +// from the server first. +function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean; onRetry: () => void }) { + const code = error instanceof ApiError ? error.code : null + const settings = code !== null && Object.hasOwn(settingsFailures, code) ? settingsFailures[code] : undefined + if (settings !== undefined) { + return ( + + Couldn't push to Calendar. {settings.reason}{' '} + + {settings.fix} + + + ) + } + return ( + + Couldn't push to Calendar.{' '} + {cached + ? `${code === 'plan_changed' ? 'The plan changed while it was being pushed.' : error.message} The plan shown came from this device's cache, not the server; read it again before pushing.` + : code === 'plan_changed' + ? 'The plan changed while it was being pushed; push it again.' + : error.message} + + ) +} + +// Unavailable is an action with nothing behind it yet (the shopping list): +// it stays focusable, so a tap or focus shows the tooltip saying why, above +// it or, at the top of the page, below. function Unavailable({ hint, variant = 'primary', diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx index 438c2de..6571f62 100644 --- a/web/src/plan/api.test.tsx +++ b/web/src/plan/api.test.tsx @@ -1,11 +1,12 @@ import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, render, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' -import { planKey, usePlan, useSwapDay, type Plan } from './api.ts' +import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushToCalendar, type SwapDay } from './api.ts' const WEEK = '2026-10-05' const PLAN = `/api/v1/plans/${WEEK}` @@ -203,3 +204,198 @@ describe('useSwapDay', () => { await act(async () => friday.send(jsonResponse(200, planBody()))) }) }) + +describe('week mutations', () => { + const NEXT = '2026-10-12' + const LATER = '2026-10-19' + + // weekMutations lists the swaps and pushes the mutation cache holds for + // the week starting on weekStart, with their status. + function weekMutations(queryClient: QueryClient, weekStart: string) { + return queryClient + .getMutationCache() + .findAll({ mutationKey: ['plans', weekStart] }) + .map((mutation) => `${String(mutation.options.mutationKey![2])} ${mutation.state.status}`) + } + + it('keeps settled swaps and pushes while their week is shown, and leaves them behind once it is not, unless one is pending', async () => { + const nextPush = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => jsonResponse(200, planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })), + [`POST ${PLAN}/push-calendar`]: () => errorResponse(500, 'internal'), + [`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'), + [`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply, + [`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'), + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook( + ({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }), + { wrapper, initialProps: { week: WEEK } }, + ) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + + // A swap and a push of the week settle: both stay while it is shown, + // the push's failure speaking for the week. + act(() => hook.result.current.swap.mutate(ORZO)) + await waitFor(() => expect(hook.result.current.swap.pending).toBe(false)) + act(() => hook.result.current.push.mutate()) + await waitFor(() => expect(hook.result.current.push.error).not.toBeNull()) + expect(weekMutations(queryClient, WEEK)).toEqual(['swap success', 'push error']) + + // Another week shown leaves them behind; a push sent there stays + // pending across showing a third week. + hook.rerender({ week: NEXT }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + expect(weekMutations(queryClient, WEEK)).toEqual([]) + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: LATER }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + expect(weekMutations(queryClient, NEXT)).toEqual(['push pending']) + hook.rerender({ week: NEXT }) + expect(hook.result.current.push.pending).toBe(true) + + // Once it settles, it stays while its week is shown and is left behind + // once another is. + await act(async () => nextPush.send(errorResponse(500, 'internal'))) + await waitFor(() => expect(hook.result.current.push.error).not.toBeNull()) + expect(weekMutations(queryClient, NEXT)).toEqual(['push error']) + hook.rerender({ week: LATER }) + expect(weekMutations(queryClient, NEXT)).toEqual([]) + }) + + it('leaves behind what settles in a week no longer shown, unless that week still has one pending or it is the failure that speaks for it', async () => { + const friday = deferred() + const monday = deferred() + const nextPush = deferred() + const laterPush = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => monday.reply, + [`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'), + [`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply, + [`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'), + [`POST /api/v1/plans/${LATER}/push-calendar`]: () => laterPush.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook( + ({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }), + { wrapper, initialProps: { week: WEEK } }, + ) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + + // Two swaps of the week and a push of the next are left pending, and a + // third week is shown. + act(() => hook.result.current.swap.mutate(ORZO)) + act(() => hook.result.current.swap.mutate(FISH)) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending'])) + hook.rerender({ week: NEXT }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: LATER }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + expect(weekMutations(queryClient, NEXT)).toEqual(['push pending']) + + // The later swap settles first: the earlier, still pending, must see it, + // so both stay. + await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])) + + // The push succeeds while the third week is shown: it is left behind, + // and the week's pending swap is not. + await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT }))) + await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([])) + expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']) + + // Once the earlier swap fails, the week has none pending: both are left + // behind, the failure not being the week's latest. + await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable'))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) + + // A push of the third week fails once another is shown: it stays, to be + // shown on coming back, until yet another week is. + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: NEXT }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + await act(async () => laterPush.send(errorResponse(500, 'internal'))) + await waitFor(() => expect(weekMutations(queryClient, LATER)).toEqual(['push error'])) + hook.rerender({ week: WEEK }) + expect(weekMutations(queryClient, LATER)).toEqual([]) + }) + + it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => { + const friday = deferred() + const monday = deferred() + const push = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => monday.reply, + [`POST ${PLAN}/push-calendar`]: () => push.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + let week!: { plan: ReturnType; swap: SwapDay; push: PushToCalendar } + let navigate!: NavigateFunction + function Week() { + week = { plan: usePlan(WEEK), swap: useSwapDay(WEEK), push: usePushToCalendar(WEEK) } + return null + } + function Navigator() { + navigate = useNavigate() + return null + } + render( + + + + + + } /> + + + + + , + ) + await waitFor(() => expect(week.plan.isSuccess).toBe(true)) + + // Two swaps and a push of the week are left pending, and Settings is + // shown. + act(() => week.swap.mutate(ORZO)) + act(() => week.swap.mutate(FISH)) + act(() => week.push.mutate()) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending', 'push pending'])) + act(() => void navigate('/settings')) + + // The swaps settle: once neither is pending they are left behind, and + // the push still pending is not. + await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success', 'push pending'])) + await act(async () => friday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['push pending'])) + + // The push succeeds: the plan as synced is recorded, and the push is + // left behind. + const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } + await act(async () => push.send(jsonResponse(200, synced))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) + expect(queryClient.getQueryData(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') }) + }) +}) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index bec55f3..0a8ee4a 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,8 +1,10 @@ -import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' +import { noteGoogleFailure } from '../settings/calendarApi.ts' export const PLANS_PATH = '/api/v1/plans' @@ -176,6 +178,82 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } +// weekMutation is the kind and week of a swap or push of a week the mutation +// cache keeps for it, or null for any other mutation. +function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { + const key = mutation.options.mutationKey + if (key === undefined || key.length !== 3 || key[0] !== 'plans' || (key[2] !== 'swap' && key[2] !== 'push')) return null + return { kind: key[2], week: String(key[1]) } +} + +// WeeksShown is what keepWeekMutations is told of the weeks shown: show +// marks a week shown until the function it returns is called. +type WeeksShown = { show: (weekStart: string) => () => void } + +const weeksShown = new WeakMap() + +// keepWeekMutations bounds the swaps and pushes the mutation cache of +// queryClient keeps, for as long as the query client lives, whatever is +// shown: it is installed on the client's own mutation cache once, the first +// time a week is shown, and is never removed, so a swap or push that settles +// after its Draft board, or the whole Plan, is gone is still collected. +// +// When a week is shown, the settled ones of every other week are removed, +// unless that week still has one pending: a pending one, and those an +// earlier pending one must see, stay across leaving its week and coming +// back. When one settles, the same is done, except that a week's latest, +// when it failed, stays until a week is next shown, so that coming straight +// back to it shows the failure. The cache holds at most the weeks shown's, +// those of weeks left pending, and one failure of each week whose last +// pending one settled since a week was last shown. +function keepWeekMutations(queryClient: QueryClient): WeeksShown { + const kept = weeksShown.get(queryClient) + if (kept !== undefined) return kept + const mutations = queryClient.getMutationCache() + // How many times each week is shown at once. + const shown = new Map() + function sweep(settled: boolean) { + // The mutations of each kind of each week not shown, in the order sent. + const left = new Map() + for (const mutation of mutations.getAll()) { + const of = weekMutation(mutation) + if (of === null || shown.has(of.week)) continue + const id = `${of.kind} ${of.week}` + left.set(id, [...(left.get(id) ?? []), mutation]) + } + for (const week of left.values()) { + if (week.some((mutation) => mutation.state.status === 'pending')) continue + const latest = week.at(-1) + for (const mutation of week) { + if (settled && mutation === latest && mutation.state.status === 'error') continue + mutations.remove(mutation) + } + } + } + mutations.subscribe((event) => { + if (event.type === 'updated' && weekMutation(event.mutation) !== null && event.mutation.state.status !== 'pending') sweep(true) + }) + const show = (weekStart: string) => { + shown.set(weekStart, (shown.get(weekStart) ?? 0) + 1) + sweep(false) + return () => { + const times = shown.get(weekStart)! - 1 + if (times === 0) shown.delete(weekStart) + else shown.set(weekStart, times) + } + } + const keeping = { show } + weeksShown.set(queryClient, keeping) + return keeping +} + +// useWeekShown marks the week starting on weekStart shown while the +// component is mounted (keepWeekMutations). +function useWeekShown(weekStart: string) { + const queryClient = useQueryClient() + useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart]) +} + // swapKey keys the swaps of the week starting on weekStart in the mutation // cache, where they outlive the Draft board that sent them. function swapKey(weekStart: string) { @@ -205,10 +283,12 @@ export type SwapDay = { // earlier one is answered with is not written over a later one, and its // failure is not the week's. dismiss leaves settled swaps behind; it is // refused while one is pending, so a later swap is always in the cache for -// an earlier, pending one to see. +// an earlier, pending one to see. Settled swaps of a week no longer shown +// are left behind too (keepWeekMutations). export function useSwapDay(weekStart: string): SwapDay { const { apiFetch } = useApi() const queryClient = useQueryClient() + useWeekShown(weekStart) const key = planKey(weekStart) const mutationKey = swapKey(weekStart) const mutations = queryClient.getMutationCache() @@ -224,7 +304,8 @@ export function useSwapDay(weekStart: string): SwapDay { const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId) const mutation = mutations.build(queryClient, { mutationKey, - // Kept until dismissed: a later swap must stay in the cache while an + // Kept until dismissed or settled in a week not shown + // (keepWeekMutations): a later swap must stay in the cache while an // earlier one is pending. gcTime: Infinity, mutationFn: async ({ date, dinner }) => { @@ -274,3 +355,85 @@ export function useSwapDay(weekStart: string): SwapDay { dismiss, } } + +// pushKey keys the pushes of the week starting on weekStart in the mutation +// cache, where they outlive the Draft board that sent them. +function pushKey(weekStart: string) { + return ['plans', weekStart, 'push'] as const +} + +// PushToCalendar is the push of one week, as the mutation cache holds it: +// whether it is pending, and why the latest failed, if it failed. +export type PushToCalendar = { + pending: boolean + error: Error | null + mutate: () => void + reset: () => void +} + +// usePushToCalendar pushes the plan of the week starting on weekStart to the +// chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The +// server answers with the plan as synced, which replaces the one shown, as a +// swap's answer does. A push refused because the plan changed meanwhile +// reads the plan again, whether or not the week is shown, and stays pending +// until that read lands: nothing is pushed again before the changed plan is +// the one shown. One Google refused the grant for reads the Google Calendar +// connection again, which is now reconnect required. +// +// The week's pushes live in the mutation cache, not in the component, as its +// swaps do, so leaving the week and coming back keeps one pending. reset +// leaves settled pushes behind; it is refused while one is pending. Settled +// pushes of a week no longer shown are left behind too +// (keepWeekMutations). +export function usePushToCalendar(weekStart: string): PushToCalendar { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + useWeekShown(weekStart) + const key = planKey(weekStart) + const mutationKey = pushKey(weekStart) + const mutations = queryClient.getMutationCache() + // The week's pushes, in the order sent. + const pushes = useMutationState({ + filters: { mutationKey }, + select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }), + }) + const latest = pushes.at(-1) + + function mutate() { + const mutation = mutations.build(queryClient, { + mutationKey, + // Kept until reset or settled in a week not shown (keepWeekMutations): + // a push pending in a week left must still be found when it is shown + // again. + gcTime: Infinity, + mutationFn: async () => { + const path = `${planPath(weekStart)}/push-calendar` + return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) + }, + onSuccess: (plan) => queryClient.setQueryData(key, plan), + onError: async (error) => { + noteGoogleFailure(queryClient, error) + if (error instanceof ApiError && error.code === 'plan_changed') { + await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + } + }, + }) + // A failure is the mutation's state, which error reports. + mutation.execute().catch(() => undefined) + } + + function reset() { + const week = mutations.findAll({ mutationKey }) + if (week.some((mutation) => mutation.state.status === 'pending')) { + throw new Error(`The week of ${weekStart} has a push pending, which cannot be reset.`) + } + for (const mutation of week) mutations.remove(mutation) + } + + return { + pending: pushes.some((push) => push.status === 'pending'), + error: latest?.status === 'error' ? latest.error! : null, + mutate, + reset, + } +} diff --git a/web/src/settings/CalendarSettingsPage.module.css b/web/src/settings/CalendarSettingsPage.module.css new file mode 100644 index 0000000..3c6f7bf --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.module.css @@ -0,0 +1,156 @@ +/* Settings · Google Calendar. */ +.page { + box-sizing: border-box; + min-height: 100%; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.shared { + margin: 0; + padding: 14px 16px; + border-radius: 26px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; +} + +.loading { + height: 220px; + border-radius: 26px; +} + +.pickerLoading { + height: 72px; + border-radius: var(--radius-md); +} + +.card { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--color-neutral-300); +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.text, +.hint, +.status, +.error, +.warning { + margin: 0; + font-family: var(--font-body); + line-height: 1.4; +} + +.text { + font-size: 15px; +} + +.hint, +.status { + font-size: 13px; + color: var(--color-neutral-700); +} + +.status:empty { + display: none; +} + +.error, +.warning { + font-size: 14px; + font-weight: 700; + color: var(--color-accent-700); +} + +/* "Connected · Dinners calendar": teal is the confirmation voice. */ +.connected { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; + color: var(--color-accent-2-800); +} + +/* A grant Google refused: the grape banner with Reconnect. */ +.reconnect { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + border-radius: 26px; + background: var(--color-accent-100); + border: 1px solid var(--color-accent-300); + color: var(--color-accent-800); +} + +.reconnectTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 20px; + font-weight: 400; +} + +.reconnectStatus { + display: flex; + flex-direction: column; + gap: 10px; +} + +.reconnectText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + line-height: 1.4; +} + +/* The connect endpoint is a page, so Connect and Reconnect are links drawn + as the primary button. */ +.connect { + align-self: flex-start; + text-decoration: none; +} + +.actions { + display: flex; + justify-content: flex-start; +} diff --git a/web/src/settings/CalendarSettingsPage.test.tsx b/web/src/settings/CalendarSettingsPage.test.tsx new file mode 100644 index 0000000..6803202 --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.test.tsx @@ -0,0 +1,384 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { GOOGLE_NOT_CONNECTED } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { CalendarSettingsPage } from './CalendarSettingsPage.tsx' + +const GOOGLE = '/api/v1/integrations/google' +const CALENDARS = '/api/v1/integrations/google/calendars' +const CALENDAR = '/api/v1/integrations/google/calendar' + +// CONNECTED is a connection pushing to the household's Dinners calendar. +const CONNECTED = { + status: 'connected', + calendar_id: 'dinners@group.calendar.google.com', + calendar_name: 'Dinners', + connected_at: '2026-10-07T15:00:00Z', + last_sync_error: null, +} + +// RECONNECT is CONNECTED once Google has refused its grant. +const RECONNECT = { + ...CONNECTED, + status: 'reconnect_required', + last_sync_error: 'Google access was revoked or expired — reconnect', +} + +// CALENDAR_LIST is the account's writable calendars, in Google's order. +const CALENDAR_LIST = [ + { id: 'sam@example.com', summary: 'sam@example.com', primary: true, accessRole: 'owner' }, + { id: 'dinners@group.calendar.google.com', summary: 'Dinners', primary: false, accessRole: 'owner' }, + { id: 'family@group.calendar.google.com', summary: 'Family', primary: false, accessRole: 'writer' }, +] + +type Responder = (body: unknown) => Response | Promise + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body. Any other request fails the test. +function stubAPI(responders: Record): Mock { + const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), ...responders } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests other than GETs, as "METHOD path" and body. +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => ({ + request: `${init?.method} ${String(input)}`, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, + })) +} + +// reads counts the GETs of path. +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function CurrentPath() { + return

{useLocation().pathname}

+} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the page at /settings/calendar in a data router. +async function renderPage() { + const api = createApiClient(fakeLocation('/settings/calendar'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + render( + + + + + } /> + } /> + + + + , + ) + return queryClient +} + +describe('CalendarSettingsPage', () => { + it('shows the screen title and goes back to Settings', async () => { + stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) }) + await renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Google Calendar' })).toBeInTheDocument() + expect(screen.getByText('Settings')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('shows "Connect Google Calendar" when not connected, opening the connect endpoint as a page', async () => { + const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) }) + await renderPage() + + const connect = await screen.findByRole('link', { name: 'Connect Google Calendar' }) + expect(connect).toHaveAttribute('href', '/api/v1/integrations/google/connect') + expect(declaredStyle(connect).background).toBe('var(--color-accent)') + expect(screen.getByRole('region', { name: 'Not connected' })).toBeInTheDocument() + // Nothing to pick yet, nor to disconnect, and Google is not asked. + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument() + expect(reads(fetchMock, CALENDARS)).toBe(0) + }) + + it('reports a status that could not load, and reads it again on Retry', async () => { + let status = () => errorResponse(500, 'internal') + stubAPI({ [`GET ${GOOGLE}`]: () => status() }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Google Calendar status could not load: internal message') + status = () => jsonResponse(200, GOOGLE_NOT_CONNECTED) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + }) + + it('refuses a status it does not know', async () => { + stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) }) + await renderPage() + + expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection') + }) + + it('shows "Connected · {calendar}" in teal-800 and the writable calendars to pick from', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + }) + await renderPage() + + const connected = await screen.findByText('Connected · Dinners') + expect(declaredStyle(connected).color).toBe('var(--color-accent-2-800)') + expect(connected.querySelector('svg')).not.toBeNull() + const picker = await screen.findByRole('combobox', { name: 'Calendar' }) + expect(picker).toHaveValue('dinners@group.calendar.google.com') + expect(within(picker).getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'sam@example.com (primary)', + 'Dinners', + 'Family', + ]) + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Connect|Reconnect/ })).not.toBeInTheDocument() + }) + + it('chooses another calendar at once, showing it saving, and reads the plans again', async () => { + const answer = deferred() + const fetchMock = stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`PUT ${CALENDAR}`]: () => answer.response, + }) + const queryClient = await renderPage() + queryClient.setQueryData(['plans', '2026-10-05'], null) + + const picker = await screen.findByRole('combobox', { name: 'Calendar' }) + fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } }) + await waitFor(() => + expect(sent(fetchMock)).toEqual([{ request: `PUT ${CALENDAR}`, body: { calendar_id: 'family@group.calendar.google.com' } }]), + ) + expect(await screen.findByText('Saving…')).toBeInTheDocument() + expect(picker).toBeDisabled() + expect(picker).toHaveValue('family@group.calendar.google.com') + + answer.send(jsonResponse(200, { calendar_id: 'family@group.calendar.google.com', calendar_name: 'Family' })) + expect(await screen.findByText('Connected · Family')).toBeInTheDocument() + expect(picker).toBeEnabled() + expect(picker).toHaveValue('family@group.calendar.google.com') + expect(screen.queryByText('Saving…')).not.toBeInTheDocument() + // Choosing another calendar makes every pushed plan changed. + expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true) + }) + + it('says a choice that was not saved, keeping the calendar chosen before', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`PUT ${CALENDAR}`]: () => errorResponse(422, 'unknown_calendar'), + }) + await renderPage() + + const picker = await screen.findByRole('combobox', { name: 'Calendar' }) + fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } }) + expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: unknown_calendar message') + expect(picker).toHaveValue('dinners@group.calendar.google.com') + expect(screen.getByText('Connected · Dinners')).toBeInTheDocument() + }) + + it('asks to choose a calendar when connected with none chosen', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...CONNECTED, calendar_id: null, calendar_name: null }), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + }) + await renderPage() + + expect(await screen.findByText('Connected')).toBeInTheDocument() + expect(await screen.findByText('Choose the calendar to push dinners to.')).toBeInTheDocument() + const picker = screen.getByRole('combobox', { name: 'Calendar' }) + expect(picker).toHaveValue('') + expect(within(picker).getByRole('option', { name: 'Choose a calendar' })).toBeDisabled() + }) + + it('says the chosen calendar can no longer be pushed to once it drops out of the list', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST.filter((calendar) => calendar.summary !== 'Dinners')), + }) + await renderPage() + + expect( + await screen.findByText('Dinners was deleted or MealQuest can no longer add dinners to it. Choose another calendar.'), + ).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Calendar' })).toHaveValue('') + }) + + it('says when the account has no calendar MealQuest can add dinners to', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, []), + }) + await renderPage() + + expect(await screen.findByText('This Google account has no calendar MealQuest can add dinners to.')).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) + + it('reports calendars that could not load, and lists them again on Retry', async () => { + let list = () => errorResponse(502, 'google_failed') + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => list(), + }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Your calendars could not load: google_failed message') + list = () => jsonResponse(200, CALENDAR_LIST) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('combobox', { name: 'Calendar' })).toHaveValue('dinners@group.calendar.google.com') + }) + + it('turns to the reconnect banner when Google refuses the grant while listing calendars', async () => { + let status: unknown = CONNECTED + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, status), + [`GET ${CALENDARS}`]: () => { + status = RECONNECT + return errorResponse(409, 'google_reconnect_required') + }, + }) + await renderPage() + + const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' }) + // Why and what to do are announced as the screen turns to the banner. + const alert = screen.getByRole('alert') + expect(banner).toContainElement(alert) + expect(alert).toHaveTextContent( + 'Google access was revoked or expired — reconnect' + 'Nothing can be pushed to Calendar until you connect again.', + ) + expect(within(alert).queryByRole('link')).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument() + }) + + it('shows a grape banner with Reconnect when reconnect is required, and offers nothing that needs Google', async () => { + const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, RECONNECT) }) + await renderPage() + + const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' }) + expect(declaredStyle(banner).background).toBe('var(--color-accent-100)') + expect(declaredStyle(banner).color).toBe('var(--color-accent-800)') + expect(banner).toHaveTextContent('Google access was revoked or expired — reconnect') + expect(banner).toHaveTextContent('Nothing can be pushed to Calendar until you connect again.') + expect(within(banner).getByRole('link', { name: 'Reconnect' })).toHaveAttribute( + 'href', + '/api/v1/integrations/google/connect', + ) + // The chosen calendar is kept, but not picked from, nor called connected. + expect(screen.getByText('Dinners go to Dinners once Google Calendar is reconnected.')).toBeInTheDocument() + expect(screen.queryByText(/^Connected/)).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(reads(fetchMock, CALENDARS)).toBe(0) + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + }) + + it('disconnects only once confirmed, then shows Connect Google Calendar', async () => { + let status: unknown = CONNECTED + const answer = deferred() + const fetchMock = stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, status), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`DELETE ${GOOGLE}`]: () => answer.response, + }) + const queryClient = await renderPage() + queryClient.setQueryData(['plans', '2026-10-05'], null) + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) + let dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + expect(dialog).toHaveTextContent('Dinners already pushed to Dinners stay on the calendar.') + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(sent(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })) + await waitFor(() => expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }])) + // While it disconnects the sheet stays, and cannot be dismissed. + expect(await within(dialog).findByRole('button', { name: 'Disconnecting…' })).toBeDisabled() + expect(within(dialog).getByRole('button', { name: 'Keep connected' })).toBeDisabled() + + status = GOOGLE_NOT_CONNECTED + answer.send(new Response(null, { status: 204 })) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + // Disconnecting forgets every pushed event, so pushed plans are changed. + expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true) + }) + + it('says a disconnect Google refused, keeping the connection', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`DELETE ${GOOGLE}`]: () => errorResponse(502, 'revoke_failed'), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) + const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent('Not disconnected: revoke_failed message') + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByText('Connected · Dinners')).toBeInTheDocument() + }) + + it('disconnects a connection that needs reconnecting', async () => { + let status: unknown = RECONNECT + const fetchMock = stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, status), + [`DELETE ${GOOGLE}`]: () => { + status = GOOGLE_NOT_CONNECTED + return new Response(null, { status: 204 }) + }, + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) + const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }]) + }) +}) diff --git a/web/src/settings/CalendarSettingsPage.tsx b/web/src/settings/CalendarSettingsPage.tsx new file mode 100644 index 0000000..ffb78b0 --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.tsx @@ -0,0 +1,239 @@ +import { CalendarDays, Check, ChevronLeft, RefreshCw, type LucideIcon } from 'lucide-react' +import { useId, useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router' +import { Button } from '../ui/Button.tsx' +import buttonStyles from '../ui/Button.module.css' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Select } from '../ui/Select.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { + GOOGLE_CONNECT_PATH, + useChooseGoogleCalendar, + useDisconnectGoogle, + useGoogleCalendars, + useGoogleConnection, + type GoogleConnection, +} from './calendarApi.ts' +import styles from './CalendarSettingsPage.module.css' +import { Confirm } from './LeaveGuard.tsx' + +// Settings · Google Calendar: connect the household's Google account, choose +// the calendar Push to Calendar writes the week's dinners to, and +// disconnect. A grant Google has refused shows a grape banner asking to +// reconnect, and nothing that needs Google is offered until then. +export function CalendarSettingsPage() { + const connection = useGoogleConnection() + return ( +
+
+

+ Push to Calendar adds one event per dinner. It is one-way: edits made in Google Calendar do not come back. +

+ {connection.isPending && } + {connection.isError && ( + void connection.refetch()}> + Google Calendar status could not load: {connection.error.message} + + )} + {connection.isSuccess && } +
+ ) +} + +function Header() { + const navigate = useNavigate() + return ( +
+ void navigate('/settings')}> + + +
+

Settings

+

Google Calendar

+
+
+ ) +} + +function Connection({ connection }: { connection: GoogleConnection }) { + switch (connection.status) { + case 'not_connected': + return ( +
+

Connect the household's Google account to push each week's plan to one of its calendars.

+ Connect Google Calendar +
+ ) + case 'reconnect_required': + return ( + <> + +
+

+ {connection.calendar_name === null + ? 'No calendar chosen. Choose one once Google Calendar is reconnected.' + : `Dinners go to ${connection.calendar_name} once Google Calendar is reconnected.`} +

+ +
+ + ) + case 'connected': + return ( +
+

+ + {connection.calendar_name === null ? 'Connected' : `Connected · ${connection.calendar_name}`} +

+ + +
+ ) + } +} + +// ReconnectBanner is the grape banner of a grant Google refused, with why +// and Reconnect. Why and what to do are an alert, as ErrorBanner's message +// is: a calendar request that meets the refusal turns the screen to it. +function ReconnectBanner({ error }: { error: string | null }) { + const titleId = useId() + return ( +
+

+ Reconnect Google Calendar +

+
+ {error !== null &&

{error}

} +

Nothing can be pushed to Calendar until you connect again.

+
+ Reconnect +
+ ) +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + const titleId = useId() + return ( + +

+ {title} +

+ {children} +
+ ) +} + +// ConnectLink opens the connect endpoint as a page: the server sends the +// browser on to Google's consent screen, and back here once Google answers. +function ConnectLink({ icon = CalendarDays, children }: { icon?: LucideIcon; children: ReactNode }) { + return ( + + + {children} + + ) +} + +// CalendarPicker chooses, from the calendars the account can write to, the +// one dinners are pushed to; a choice is saved at once. A calendar chosen +// before that has since been deleted or become read-only is no longer +// listed, and is said to be so. +function CalendarPicker({ connection }: { connection: GoogleConnection }) { + const calendars = useGoogleCalendars(true) + const choose = useChooseGoogleCalendar() + + if (calendars.isPending) return + if (calendars.isError) { + return ( + void calendars.refetch()}> + Your calendars could not load: {calendars.error.message} + + ) + } + if (calendars.data.length === 0) { + return

This Google account has no calendar MealQuest can add dinners to.

+ } + + const listed = calendars.data.some((calendar) => calendar.id === connection.calendar_id) + const value = choose.isPending ? choose.variables : listed ? connection.calendar_id! : '' + return ( + <> + {connection.calendar_id === null &&

Choose the calendar to push dinners to.

} + {connection.calendar_id !== null && !listed && ( +

+ {connection.calendar_name} was deleted or MealQuest can no longer add dinners to it. Choose another calendar. +

+ )} + +

+ {choose.isPending && 'Saving…'} +

+ {choose.isError && ( +

+ Not saved: {choose.error.message} +

+ )} +

+ After choosing another calendar, the next push writes the whole week there. Dinners already pushed stay where they + are. +

+ + ) +} + +// Disconnect revokes MealQuest's access at Google, after asking. +function Disconnect({ calendar }: { calendar: string | null }) { + const [confirming, setConfirming] = useState(false) + const disconnect = useDisconnectGoogle() + return ( + <> +
+ +
+ setConfirming(false)} + onConfirm={() => disconnect.mutate(undefined, { onSuccess: () => setConfirming(false) })} + > + MealQuest gives up its access to your Google account and stops pushing dinners.{' '} + {calendar === null ? 'Dinners already pushed' : `Dinners already pushed to ${calendar}`} stay on the calendar. + + + ) +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index afb28dd..a039276 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -4,7 +4,7 @@ import { Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' +import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' @@ -22,7 +22,7 @@ afterEach(() => { }) const REGULARS_PATH = '/api/v1/dinners?status=regular' -const CALENDAR_PATH = '/api/v1/settings/calendar' +const CALENDAR_PATH = '/api/v1/integrations/google' const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' // stubSettings answers as today's server does (see SETTINGS_API), with @@ -96,9 +96,9 @@ describe('SettingsPage', () => { expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ REGULARS_PATH, + CALENDAR_PATH, '/api/v1/settings/ai', '/api/v1/settings/ai/models', - CALENDAR_PATH, '/api/v1/settings/household', NOTIFICATIONS_PATH, '/api/v1/settings/recipe-search', @@ -109,7 +109,6 @@ describe('SettingsPage', () => { stubSettings() renderSettings() expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument() - expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument() expect( await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'), ).toBeInTheDocument() @@ -119,7 +118,8 @@ describe('SettingsPage', () => { it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => { stubSettings({ [REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }), - [CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }), + [CALENDAR_PATH]: () => + jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }), [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }), }) renderSettings() @@ -133,7 +133,7 @@ describe('SettingsPage', () => { it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => { stubSettings({ [REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }), - [CALENDAR_PATH]: () => jsonResponse(200, { connected: false }), + [CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED), [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }), }) renderSettings() @@ -144,6 +144,15 @@ describe('SettingsPage', () => { ).toBeInTheDocument() }) + it.each([ + ['connected with no calendar chosen', { status: 'connected' }, 'Connected · no calendar chosen'], + ['reconnect required', { status: 'reconnect_required', calendar_id: 'c1', calendar_name: 'Dinners' }, 'Reconnect needed'], + ])('says Google Calendar is %s', async (_, connection, said) => { + stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, ...connection }) }) + renderSettings() + expect(await within(row('Google Calendar')).findByText(said)).toBeInTheDocument() + }) + it('reports a status endpoint failure other than a missing endpoint', async () => { stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') }) renderSettings() @@ -151,10 +160,10 @@ describe('SettingsPage', () => { expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar') }) - it('refuses a connected calendar without a name', async () => { - stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) }) + it('refuses a Google Calendar status it does not know', async () => { + stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) }) renderSettings() - expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status') + expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection') }) it('shows another provider and model as the settings name them', async () => { diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx index e96881e..425623a 100644 --- a/web/src/settings/SettingsPage.tsx +++ b/web/src/settings/SettingsPage.tsx @@ -11,16 +11,15 @@ import { Tag } from '../ui/Tag.tsx' import { useBreakpoint } from '../shell/useBreakpoint.ts' import { aiStatus, - calendarStatus, formatClockTime, notificationsStatus, regularDinnersStatus, useAISettings, - useCalendarStatus, useHouseholdSettings, useNotificationsStatus, useRegularDinnersCount, } from './api.ts' +import { calendarStatus, useGoogleConnection } from './calendarApi.ts' import styles from './SettingsPage.module.css' import { SettingsTabs } from './SettingsTabs.tsx' @@ -39,14 +38,14 @@ export function SettingsPage() { } // The mobile settings list (handoff mock 1p). Each row opens its settings -// page and shows a live status line. Regular dinners, Google Calendar and -// Notifications & Google Chat read endpoints their features have not served -// yet; until then each shows its not-yet-available state (see api.ts). +// page and shows a live status line. Regular dinners and Notifications & +// Google Chat read endpoints their features have not served yet; until then +// each shows its not-yet-available state (see api.ts). function SettingsList() { const household = useHouseholdSettings() const ai = useAISettings() const regulars = useRegularDinnersCount() - const calendar = useCalendarStatus() + const calendar = useGoogleConnection() const notifications = useNotificationsStatus() const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError) diff --git a/web/src/settings/SettingsTabs.test.tsx b/web/src/settings/SettingsTabs.test.tsx index 87a4983..f3cdc36 100644 --- a/web/src/settings/SettingsTabs.test.tsx +++ b/web/src/settings/SettingsTabs.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' +import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' @@ -154,7 +154,7 @@ describe('SettingsPage across the 768 px breakpoint', () => { it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), - 'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(767) @@ -169,7 +169,7 @@ describe('SettingsPage across the 768 px breakpoint', () => { it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), - 'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(1180) diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 058caf0..bab2bcc 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -14,9 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test' // dinners read API. Until it lands the server answers 404 and the Regular // dinners row says the count is unavailable. export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular' -// TODO(Feature 16): the Google Calendar integration status. Until Feature 16 -// serves it the server answers 404, which is the truth: not connected. -export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar' // TODO(notifications feature): the Web Push and Google Chat status. Until it // is served the server answers 404, which is the truth: push is off and no // Google Chat space is connected. @@ -516,30 +513,6 @@ export function regularDinnersStatus(count: number | null): string { return `${count} on rotation` } -// CalendarStatus is the Google Calendar integration status; calendar_name -// is the connected calendar's name. -export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string } - -export function useCalendarStatus() { - const { apiFetch } = useApi() - return useQuery({ - queryKey: ['settings', 'calendar'], - queryFn: () => - fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => { - if (isRecord(body) && body.connected === false) return { connected: false } - if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') { - return { connected: true, calendar_name: body.calendar_name } - } - throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`) - }), - }) -} - -export function calendarStatus(status: CalendarStatus | null): string { - if (status === null || !status.connected) return 'Not connected' - return `Connected · ${status.calendar_name}` -} - // NotificationsStatus is whether Web Push is on and the connected Google // Chat space's name, null when none is. export type NotificationsStatus = { push: boolean; chat_space: string | null } diff --git a/web/src/settings/calendarApi.ts b/web/src/settings/calendarApi.ts new file mode 100644 index 0000000..c706e54 --- /dev/null +++ b/web/src/settings/calendarApi.ts @@ -0,0 +1,165 @@ +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' +import { ApiError, useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' + +export const GOOGLE_PATH = '/api/v1/integrations/google' +// GOOGLE_CONNECT_PATH starts connecting: the browser opens it as a page, +// and the server sends it on to Google's consent screen and, once Google +// answers, back to /settings/calendar. +export const GOOGLE_CONNECT_PATH = `${GOOGLE_PATH}/connect` +export const GOOGLE_CALENDARS_PATH = `${GOOGLE_PATH}/calendars` +export const GOOGLE_CALENDAR_PATH = `${GOOGLE_PATH}/calendar` + +// GOOGLE_RECONNECT_REQUIRED is the API error code of a call Google refused +// the stored grant for: only connecting again helps. +export const GOOGLE_RECONNECT_REQUIRED = 'google_reconnect_required' + +export const GOOGLE_STATUSES = ['not_connected', 'connected', 'reconnect_required'] as const + +// GoogleStatus is reconnect_required once Google has refused the stored +// grant: the connection and its chosen calendar are kept, but nothing can be +// pushed until the household connects again. +export type GoogleStatus = (typeof GOOGLE_STATUSES)[number] + +// GoogleConnection is GET /api/v1/integrations/google: the connection's +// status, the calendar chosen to push dinners to, when it was connected and +// why it needs a reconnect, each null when there is none. +export type GoogleConnection = { + status: GoogleStatus + calendar_id: string | null + calendar_name: string | null + connected_at: string | null + last_sync_error: string | null +} + +// GoogleCalendar is a calendar MealQuest can push dinners to: one the +// connected account can write to. +export type GoogleCalendar = { id: string; summary: string; primary: boolean } + +export const GOOGLE_KEY = ['integrations', 'google'] as const +const GOOGLE_CALENDARS_KEY = ['integrations', 'google', 'calendars'] as const + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function parseConnection(where: string, body: unknown): GoogleConnection { + if ( + !isRecord(body) || + !GOOGLE_STATUSES.includes(body.status as GoogleStatus) || + !isStringOrNull(body.calendar_id) || + !isStringOrNull(body.calendar_name) || + !isStringOrNull(body.connected_at) || + !isStringOrNull(body.last_sync_error) + ) { + throw new Error(`${where}: response is not a Google Calendar connection`) + } + return { + status: body.status as GoogleStatus, + calendar_id: body.calendar_id, + calendar_name: body.calendar_name, + connected_at: body.connected_at, + last_sync_error: body.last_sync_error, + } +} + +// reconnectRequired says whether error is Google refusing the stored grant. +export function reconnectRequired(error: Error): boolean { + return error instanceof ApiError && error.code === GOOGLE_RECONNECT_REQUIRED +} + +// noteGoogleFailure reads the connection again after a call Google refused +// the grant for: the server now reports it as reconnect_required. +export function noteGoogleFailure(queryClient: QueryClient, error: Error) { + if (reconnectRequired(error)) void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true }) +} + +// useGoogleConnection is the Google Calendar connection. +export function useGoogleConnection() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: GOOGLE_KEY, + queryFn: async () => parseConnection(`GET ${GOOGLE_PATH}`, await apiFetch(GOOGLE_PATH)), + }) +} + +// useGoogleCalendars lists the connected account's calendars MealQuest can +// push to, in Google's order. It asks Google, so it is read only while +// connected. +export function useGoogleCalendars(enabled: boolean) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useQuery({ + queryKey: GOOGLE_CALENDARS_KEY, + enabled, + queryFn: async (): Promise => { + let body: unknown + try { + body = await apiFetch(GOOGLE_CALENDARS_PATH) + } catch (error) { + noteGoogleFailure(queryClient, error as Error) + throw error + } + if ( + !Array.isArray(body) || + !body.every((c) => isRecord(c) && typeof c.id === 'string' && typeof c.summary === 'string' && typeof c.primary === 'boolean') + ) { + throw new Error(`GET ${GOOGLE_CALENDARS_PATH}: response is not a calendar list`) + } + return body.map((c: Record) => ({ id: c.id as string, summary: c.summary as string, primary: c.primary as boolean })) + }, + }) +} + +// useChooseGoogleCalendar chooses the calendar dinners are pushed to. +// Choosing another calendar than the one pushed to makes every pushed plan +// changed (the next push writes the whole week to the new calendar), so the +// plans are read again. +export function useChooseGoogleCalendar() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (calendarId: string) => { + const where = `PUT ${GOOGLE_CALENDAR_PATH}` + const body = await apiFetch(GOOGLE_CALENDAR_PATH, { method: 'PUT', body: { calendar_id: calendarId } }) + if (!isRecord(body) || body.calendar_id !== calendarId || typeof body.calendar_name !== 'string') { + throw new Error(`${where}: response is not the calendar chosen`) + } + return { calendar_id: calendarId, calendar_name: body.calendar_name } + }, + onSuccess: (chosen) => { + queryClient.setQueryData(GOOGLE_KEY, (connection) => connection && { ...connection, ...chosen }) + void queryClient.invalidateQueries({ queryKey: ['plans'] }) + }, + onError: (error) => noteGoogleFailure(queryClient, error), + }) +} + +// useDisconnectGoogle revokes MealQuest's access at Google and forgets the +// connection and every pushed event, making each pushed plan changed: the +// connection and the plans are read again. +export function useDisconnectGoogle() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => apiFetch(GOOGLE_PATH, { method: 'DELETE' }), + onSuccess: () => { + queryClient.removeQueries({ queryKey: GOOGLE_CALENDARS_KEY }) + void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true }) + void queryClient.invalidateQueries({ queryKey: ['plans'] }) + }, + }) +} + +// calendarStatus is the settings list's line for the connection: the +// handoff's "Connected · Dinners calendar". +export function calendarStatus(connection: GoogleConnection): string { + switch (connection.status) { + case 'not_connected': + return 'Not connected' + case 'reconnect_required': + return 'Reconnect needed' + case 'connected': + return connection.calendar_name === null ? 'Connected · no calendar chosen' : `Connected · ${connection.calendar_name}` + } +} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 60c786f..3a377a6 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -51,13 +51,23 @@ export const RECIPE_SEARCH_SETTINGS = { updated_at: '2026-10-01T15:00:00.000Z', } +// GOOGLE_NOT_CONNECTED is a GET /api/v1/integrations/google body: nothing +// connected. +export const GOOGLE_NOT_CONNECTED = { + status: 'not_connected', + calendar_id: null, + calendar_name: null, + connected_at: null, + last_sync_error: null, +} + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as -// today's server does: the dinners, Google Calendar and notifications status -// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so +// today's server does: the dinners and notifications status endpoints do not +// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so // it also reads the AI models and recipe search settings. export const SETTINGS_API = { '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), - '/api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + '/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),