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